Compare commits
33 commits
master
...
develop-fa
Author | SHA1 | Date | |
---|---|---|---|
|
426d34a2d3 | ||
|
2255f9edcd | ||
|
96573e7d48 | ||
|
113d35201b | ||
|
5c75a71609 | ||
|
a7c3f6dfc9 | ||
|
abf61d651c | ||
|
f20423280b | ||
|
6bcdda506a | ||
|
15ef4c03d1 | ||
|
8b79f7fca4 | ||
|
e70436104b | ||
|
b382beb5a6 | ||
|
b9996a9fe6 | ||
|
0de01ac270 | ||
|
4fb5285c66 | ||
|
17a72ecdfc | ||
|
822a041abe | ||
|
fc08032f0a | ||
|
dde11213c0 | ||
|
a58924c6da | ||
|
f9777a9274 | ||
|
944a75f51a | ||
|
678bfa9736 | ||
|
4d5875a3aa | ||
|
23aa9a905b | ||
|
5c717d4ee9 | ||
|
cd2b0c0ca1 | ||
|
3b50678d0a | ||
|
d381888f42 | ||
|
906b8705b9 | ||
|
e45bf760b6 | ||
3987c69a25 |
113 changed files with 1373 additions and 14248 deletions
36
.github/workflows/python-app.yml
vendored
Normal file
36
.github/workflows/python-app.yml
vendored
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
# This workflow will install Python dependencies, run tests and lint with a single version of Python
|
||||||
|
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
|
||||||
|
|
||||||
|
name: Python application
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "**" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "**" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Set up Python 3.10
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: "3.10"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install flake8 pytest requests
|
||||||
|
if [ -f backend/requirements.txt ]; then pip install -r backend/requirements.txt; fi
|
||||||
|
- name: Lint with flake8
|
||||||
|
run: |
|
||||||
|
# stop the build if there are Python syntax errors or undefined names
|
||||||
|
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||||
|
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
||||||
|
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||||
|
- name: Test with pytest
|
||||||
|
run: |
|
||||||
|
pytest
|
138
backend/.dockerignore
Normal file
138
backend/.dockerignore
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
# 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/
|
||||||
|
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/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .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/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
138
backend/.gitignore
vendored
Normal file
138
backend/.gitignore
vendored
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
# 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/
|
||||||
|
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/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .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/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
12
backend/Dockerfile
Normal file
12
backend/Dockerfile
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
FROM python:3.10
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# Install requirements before copying the rest of data to make use of docker's cache layers
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy files from host to workdir in container
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["python", "src/main.py"]
|
7
backend/Makefile
Normal file
7
backend/Makefile
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
.PHONY = default test
|
||||||
|
|
||||||
|
default:
|
||||||
|
@echo "Please specify a make target"
|
||||||
|
|
||||||
|
test:
|
||||||
|
pytest
|
11
backend/README.md
Normal file
11
backend/README.md
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
# Juggl API
|
||||||
|
|
||||||
|
This is the new Juggl API created with FastAPI as http framework and SQLModel as ORM.
|
||||||
|
|
||||||
|
The documentation is split in several files for better structure.
|
||||||
|
|
||||||
|
Table of Contents:
|
||||||
|
|
||||||
|
- [Installation](docs/installation.md)
|
||||||
|
- [Guidelines](docs/guidelines.md)
|
||||||
|
- [Testing](docs/testing.md)
|
7
backend/docker-compose.yml
Normal file
7
backend/docker-compose.yml
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8192:8192"
|
11
backend/docs/guidelines.md
Normal file
11
backend/docs/guidelines.md
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
# Guidelines
|
||||||
|
|
||||||
|
Guidelines we want and should follow for Juggl API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- All routes and should be `async` for maximum performance
|
||||||
|
|
||||||
|
- All routes should specify a `response_model` in the route decorator if they return anything. The same type should
|
||||||
|
be used as type hint for the function return type. FastAPI is unfortunately not yet smart enough to determine the
|
||||||
|
`response_model` from the return type hint. Maybe we should consider making a pull request...
|
92
backend/docs/installation.md
Normal file
92
backend/docs/installation.md
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
First, install Python in version ≥ 3.9
|
||||||
|
|
||||||
|
There are several ways to run Juggl api.
|
||||||
|
|
||||||
|
- [Directly via virtual environment](#virtual-environment)
|
||||||
|
- [In an automatically managed docker container](#docker-compose)
|
||||||
|
- [In a manually created docker container](#manual-container)
|
||||||
|
|
||||||
|
## Virtual Environment
|
||||||
|
|
||||||
|
Go to the backend directory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cd backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Create virtual environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ python -m venv/ ./venv/
|
||||||
|
```
|
||||||
|
|
||||||
|
Activate virtual environment (Linux):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ source venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
Activate virtual environment (Windows):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ .venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
Run inside `backend/`:
|
||||||
|
|
||||||
|
> Note the `-m` here. This causes python to execute the program as a module, not as a script, which would result in
|
||||||
|
> broken imports.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ python -m src.main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Install [Docker](https://docs.docker.com/get-docker/) and [Docker-Compose](https://docs.docker.com/compose/install/).
|
||||||
|
|
||||||
|
### Docker-compose
|
||||||
|
|
||||||
|
Automatically set up all dependencies, export ports and start container(s) with docker-compose.
|
||||||
|
|
||||||
|
Build composed container setup:
|
||||||
|
```bash
|
||||||
|
$ docker-compose build
|
||||||
|
```
|
||||||
|
|
||||||
|
Start and block terminal with output
|
||||||
|
```bash
|
||||||
|
$ docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
Start in background (as deamon)
|
||||||
|
```bash
|
||||||
|
$ docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop all containers
|
||||||
|
```bash
|
||||||
|
$ docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual container
|
||||||
|
|
||||||
|
Build and execute the docker container manually.
|
||||||
|
|
||||||
|
#### Build container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t juggl-api .
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Start container
|
||||||
|
|
||||||
|
Execute the previously bild container.
|
||||||
|
|
||||||
|
Don't forget to export the port from the container to your host system with `-p HOST_PORT:CONTAINER_PORT`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -p 8192:8192 juggl-api
|
||||||
|
```
|
13
backend/docs/testing.md
Normal file
13
backend/docs/testing.md
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
# Testing
|
||||||
|
|
||||||
|
To execute the tests, you need to install pytest.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ pip3 install pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, you can run the tests with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ pytest
|
||||||
|
```
|
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
uvicorn~=0.15.0
|
||||||
|
fastapi~=0.70.0
|
||||||
|
pydantic~=1.8.2
|
||||||
|
sqlmodel~=0.0.5
|
||||||
|
SQLAlchemy~=1.4.28
|
||||||
|
pytest~=6.2.5
|
0
backend/src/__init__.py
Normal file
0
backend/src/__init__.py
Normal file
29
backend/src/database.py
Normal file
29
backend/src/database.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
from sqlmodel import SQLModel, create_engine, Session
|
||||||
|
|
||||||
|
db_name = "database.db"
|
||||||
|
db_connection_url = f"sqlite:///{db_name}"
|
||||||
|
|
||||||
|
# Disable check_same_thread. Default is True, but FastAPI may use different threads for handling one request due to a
|
||||||
|
# paused async function may resume in another thread. We will make sure that sessions do not get shared between
|
||||||
|
# requests by injecting the session as dependency.
|
||||||
|
connect_args = {"check_same_thread": False}
|
||||||
|
|
||||||
|
# Connect to database
|
||||||
|
engine = create_engine(db_connection_url, echo=True, connect_args=connect_args)
|
||||||
|
|
||||||
|
|
||||||
|
def create_db_and_tables():
|
||||||
|
"""Creates database and tables if they do not exist yet."""
|
||||||
|
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def get_session():
|
||||||
|
"""Yields a database session. This method is used to inject a session into request handling functions.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
async def my_route(*, session: Session = Depends(get_session), hero: HeroCreate)
|
||||||
|
"""
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
yield session
|
100
backend/src/main.py
Normal file
100
backend/src/main.py
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
|
|
||||||
|
from src import database
|
||||||
|
from src.routes.projects import router as project_router
|
||||||
|
from src.routes.tags import router as tag_router
|
||||||
|
from src.routes.records import router as record_router
|
||||||
|
|
||||||
|
|
||||||
|
description = """
|
||||||
|
Welcome to the Juggl API.
|
||||||
|
|
||||||
|
This API allows you to retrieve your timetracking data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Juggl API",
|
||||||
|
description=description,
|
||||||
|
contact={ # Optional contact information. See https://fastapi.tiangolo.com/tutorial/metadata/
|
||||||
|
"name": "Juggl Management",
|
||||||
|
"url": "https://juggl.giller.dev/contact",
|
||||||
|
"email": "help@juggl.giller.dev",
|
||||||
|
},
|
||||||
|
docs_url="/" # Documentation on start page
|
||||||
|
)
|
||||||
|
"""The main FastAPI instance"""
|
||||||
|
|
||||||
|
|
||||||
|
# Enable CORS to allow requests from other domains/origins.
|
||||||
|
# See https://en.wikipedia.org/wiki/Cross-origin_resource_sharing
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[
|
||||||
|
# Allow **all** foreign requests to access this API.
|
||||||
|
'*'
|
||||||
|
],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Add HTTP compression for responses with more than 500 bytes
|
||||||
|
app.add_middleware(GZipMiddleware)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def on_startup():
|
||||||
|
"""Code executed on HTTP server startup. Until now only creating databases and tables."""
|
||||||
|
|
||||||
|
database.create_db_and_tables()
|
||||||
|
|
||||||
|
|
||||||
|
# Add subroutes
|
||||||
|
app.include_router(project_router)
|
||||||
|
app.include_router(tag_router)
|
||||||
|
app.include_router(record_router)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
if args.dev:
|
||||||
|
start_dev_server(args.port)
|
||||||
|
else:
|
||||||
|
start_production_server(args.port)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
"""Parses command line arguments on application startup."""
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Juggl Backend API v2. Start a server listening on port.",
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter # show default values
|
||||||
|
)
|
||||||
|
parser.add_argument("--dev", default=False, action=argparse.BooleanOptionalAction,
|
||||||
|
help="Enable auto-reloading on file-save")
|
||||||
|
parser.add_argument("--port", type=int, default=8192, help="Port on which the API should run")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def start_dev_server(port: int):
|
||||||
|
"""Starts a development server with auto-reloading on port."""
|
||||||
|
|
||||||
|
# Host="0.0.0.0" makes the application accessible from other IPs. Necessary when running inside docker
|
||||||
|
uvicorn.run("src.main:app", reload=True, port=port, host="0.0.0.0")
|
||||||
|
|
||||||
|
|
||||||
|
def start_production_server(port: int):
|
||||||
|
"""Starts a production, i.e. performance-optimized server on port."""
|
||||||
|
|
||||||
|
# Host="0.0.0.0" makes the application accessible from other IPs. Necessary when running inside docker
|
||||||
|
uvicorn.run("src.main:app", port=port, host="0.0.0.0")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
0
backend/src/models/__init__.py
Normal file
0
backend/src/models/__init__.py
Normal file
89
backend/src/models/models.py
Normal file
89
backend/src/models/models.py
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from sqlmodel import SQLModel, Field, Relationship
|
||||||
|
|
||||||
|
|
||||||
|
class RecordTagLink(SQLModel, table=True):
|
||||||
|
record_id: Optional[int] = Field(default=None, foreign_key="record.id", primary_key=True)
|
||||||
|
tag_id: Optional[int] = Field(default=None, foreign_key="tag.id", primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectBase(SQLModel):
|
||||||
|
"""Superclass model which all project classes have in common."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
creation_date: datetime = Field(default=datetime.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCreate(ProjectBase):
|
||||||
|
"""Model used when a user creates a new project."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Project(ProjectBase, table=True):
|
||||||
|
"""Model used inside the database."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
records: List["Record"] = Relationship(back_populates="project")
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectRead(ProjectBase):
|
||||||
|
"""Model used when querying information about a module."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
creation_date: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RecordBase(SQLModel):
|
||||||
|
"""Superclass model that all record classes have in common."""
|
||||||
|
|
||||||
|
start: datetime = Field(default=datetime.now())
|
||||||
|
end: Optional[datetime] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordCreate(RecordBase):
|
||||||
|
"""Model used when a user creates a record."""
|
||||||
|
|
||||||
|
project_id: int
|
||||||
|
tags: List[int] = Field(default=list())
|
||||||
|
|
||||||
|
|
||||||
|
class Record(RecordBase, table=True):
|
||||||
|
"""Model used inside the database."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
project: Optional[Project] = Relationship(back_populates="records")
|
||||||
|
project_id: Optional[int] = Field(default=None, foreign_key="project.id")
|
||||||
|
tags: List["Tag"] = Relationship(back_populates="records", link_model=RecordTagLink)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordRead(RecordBase):
|
||||||
|
"""Model used when a user queries a record."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
project_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class TagBase(SQLModel):
|
||||||
|
"""Superclass model which all tag classes have in common."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class TagCreate(TagBase):
|
||||||
|
"""Model used when creating a new model."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(TagBase, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
records: List[Record] = Relationship(back_populates="tags", link_model=RecordTagLink)
|
||||||
|
|
||||||
|
|
||||||
|
class TagRead(TagBase):
|
||||||
|
"""Model used when querying information about a tag."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
0
backend/src/routes/__init__.py
Normal file
0
backend/src/routes/__init__.py
Normal file
39
backend/src/routes/conftest.py
Normal file
39
backend/src/routes/conftest.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
# Conftest.py includes globally used functions. These are especially fixtures, which are used for dependency injection.
|
||||||
|
# See https://www.tutorialspoint.com/pytest/pytest_conftest_py.htm
|
||||||
|
# See https://gist.github.com/peterhurford/09f7dcda0ab04b95c026c60fa49c2a68
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlmodel import create_engine, Session, SQLModel
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel.pool import StaticPool
|
||||||
|
|
||||||
|
from src import database
|
||||||
|
from src.main import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="session")
|
||||||
|
def session_fixture():
|
||||||
|
"""Creates a mock session, that access an in-memory temporary database."""
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite://", # In memory database
|
||||||
|
connect_args={"check_same_thread": False}, # FastAPI's async functions may execute on different threads
|
||||||
|
poolclass=StaticPool # All threads should access shared memory
|
||||||
|
)
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
with Session(engine) as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="client")
|
||||||
|
def client_fixture(session: Session):
|
||||||
|
"""Creates a FastAPI TestClient with a mocked session dependency. This causes FastAPI to inject the fake session
|
||||||
|
into path operation functions, so that we have full control over the mocked database."""
|
||||||
|
|
||||||
|
def get_session_override():
|
||||||
|
return session
|
||||||
|
|
||||||
|
app.dependency_overrides[database.get_session] = get_session_override
|
||||||
|
client = TestClient(app)
|
||||||
|
yield client
|
||||||
|
app.dependency_overrides.clear()
|
93
backend/src/routes/projects.py
Normal file
93
backend/src/routes/projects.py
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import sqlalchemy.exc
|
||||||
|
from fastapi import APIRouter, status, Path, Depends, HTTPException
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from src import database
|
||||||
|
from src.models.models import ProjectCreate, ProjectRead, Project
|
||||||
|
|
||||||
|
router = APIRouter(prefix='/projects', tags=["Projects"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[ProjectRead], summary="Get a list of all projects")
|
||||||
|
async def all_projects(
|
||||||
|
*,
|
||||||
|
session: Session = Depends(database.get_session)
|
||||||
|
) -> list[Project]:
|
||||||
|
"""Returns a list of all projects"""
|
||||||
|
|
||||||
|
projects = session.exec(select(Project)).all()
|
||||||
|
return projects
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=ProjectRead)
|
||||||
|
async def add_project(
|
||||||
|
*,
|
||||||
|
project: ProjectCreate,
|
||||||
|
session: Session = Depends(database.get_session)
|
||||||
|
):
|
||||||
|
"""Add a project."""
|
||||||
|
|
||||||
|
db_project = Project.from_orm(project)
|
||||||
|
session.add(db_project)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_project)
|
||||||
|
return db_project
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{id}", response_model=ProjectRead)
|
||||||
|
async def get_project(
|
||||||
|
*,
|
||||||
|
id: int = Path(..., title="ID of the project"),
|
||||||
|
session: Session = Depends(database.get_session)
|
||||||
|
) -> Project:
|
||||||
|
"""Fetch a project by name."""
|
||||||
|
|
||||||
|
project = session.get(Project, id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_project(
|
||||||
|
*,
|
||||||
|
id: int = Path(..., title="ID of the project"),
|
||||||
|
session: Session = Depends(database.get_session)
|
||||||
|
):
|
||||||
|
"""Delete a module specified by name."""
|
||||||
|
|
||||||
|
project = session.get(Project, id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
session.delete(project)
|
||||||
|
session.commit()
|
||||||
|
# TODO: This method implicitly returns None, which will be converted to null by FastAPI, which triggers a
|
||||||
|
# warning, because the method returns with header HTTP_204_NO_CONTENT, but has content
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{id}", status_code=status.HTTP_200_OK,response_model=ProjectRead)
|
||||||
|
async def patch_project(
|
||||||
|
*,
|
||||||
|
project: ProjectCreate,
|
||||||
|
id: int = Path(..., title="ID of the project"),
|
||||||
|
session: Session = Depends(database.get_session)
|
||||||
|
) -> Project:
|
||||||
|
"""Apply partial updates to a project."""
|
||||||
|
|
||||||
|
# Search for project with name in database
|
||||||
|
db_project = session.get(Project, id)
|
||||||
|
if not db_project:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# Fetch project from database
|
||||||
|
for key, value in project.dict(exclude_unset=True).items():
|
||||||
|
setattr(db_project, key, value)
|
||||||
|
|
||||||
|
# Write modified project to database
|
||||||
|
session.add(db_project)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_project)
|
||||||
|
return db_project
|
89
backend/src/routes/records.py
Normal file
89
backend/src/routes/records.py
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
from fastapi import APIRouter, status, Path, Depends, HTTPException
|
||||||
|
from sqlmodel import select, Session
|
||||||
|
|
||||||
|
from src import database
|
||||||
|
from src.models.models import Record, RecordRead, RecordCreate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/records", tags=["Records"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[RecordRead], summary="Get a list of all records")
|
||||||
|
async def all_records(
|
||||||
|
*,
|
||||||
|
session: Session = Depends(database.get_session)
|
||||||
|
) -> list[Record]:
|
||||||
|
"""Returns a list of all records."""
|
||||||
|
|
||||||
|
records = session.exec(select(Record)).all()
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def add_record(
|
||||||
|
*,
|
||||||
|
record: RecordCreate,
|
||||||
|
session: Session = Depends(database.get_session)
|
||||||
|
):
|
||||||
|
"""Start a record."""
|
||||||
|
|
||||||
|
db_record = Record.from_orm(record)
|
||||||
|
session.add(db_record)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_record)
|
||||||
|
return db_record
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{id}", response_model=RecordRead)
|
||||||
|
async def get_record(
|
||||||
|
*,
|
||||||
|
id: str = Path(..., title="ID of the record"),
|
||||||
|
session: Session = Depends(database.get_session)
|
||||||
|
) -> Record:
|
||||||
|
"""Fetch a record by id."""
|
||||||
|
|
||||||
|
record = session.get(Record, id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_record(
|
||||||
|
*,
|
||||||
|
id: str = Path(..., title="ID of the record"),
|
||||||
|
session: Session = Depends(database.get_session)
|
||||||
|
):
|
||||||
|
"""Delete a record specified by id."""
|
||||||
|
|
||||||
|
record = session.get(Record, id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
session.delete(record)
|
||||||
|
session.commit()
|
||||||
|
# TODO: This method implicitly returns None, which will be converted to null by FastAPI, which triggers a
|
||||||
|
# warning, because the method returns with header HTTP_204_NO_CONTENT, but has content
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{id}", summary="Apply partial updates to a record by id")
|
||||||
|
async def patch_record(
|
||||||
|
*,
|
||||||
|
record: RecordCreate,
|
||||||
|
id: str = Path(..., title="ID of the record"),
|
||||||
|
session: Session = Depends(database.get_session)
|
||||||
|
):
|
||||||
|
"""Apply partial updates to a record."""
|
||||||
|
|
||||||
|
# Fetch record by id from database
|
||||||
|
db_record = session.get(Record, id)
|
||||||
|
if not db_record:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# Set all provided arguments in the database record
|
||||||
|
for key, value in record.dict(exclude_unset=True).items():
|
||||||
|
setattr(db_record, key, value)
|
||||||
|
|
||||||
|
# Write modified record to database
|
||||||
|
session.add(db_record)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_record)
|
||||||
|
return db_record
|
93
backend/src/routes/tags.py
Normal file
93
backend/src/routes/tags.py
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import sqlalchemy.exc
|
||||||
|
from fastapi import APIRouter, status, Path, Depends, HTTPException
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from src import database
|
||||||
|
from src.models.models import TagRead, TagCreate, Tag
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tags", tags=["Tags"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[TagRead], summary="Get a list of all tags")
|
||||||
|
async def all_tags(
|
||||||
|
*,
|
||||||
|
session: Session = Depends(database.get_session)
|
||||||
|
) -> list[Tag]:
|
||||||
|
"""Returns a list of all tags."""
|
||||||
|
|
||||||
|
tags = session.exec(select(Tag)).all()
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def add_tag(
|
||||||
|
*,
|
||||||
|
tag: TagCreate,
|
||||||
|
session: Session = Depends(database.get_session)
|
||||||
|
):
|
||||||
|
"""Add a tag."""
|
||||||
|
|
||||||
|
db_tag = Tag.from_orm(tag)
|
||||||
|
session.add(db_tag)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_tag)
|
||||||
|
return db_tag
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{id}", response_model=TagRead)
|
||||||
|
async def get_tag(
|
||||||
|
*,
|
||||||
|
id: str = Path(..., title="ID of the tag"),
|
||||||
|
session: Session = Depends(database.get_session)
|
||||||
|
) -> Tag:
|
||||||
|
"""Fetch a tag by name."""
|
||||||
|
|
||||||
|
tag = session.get(Tag, id)
|
||||||
|
if not tag:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
return tag
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_tag(
|
||||||
|
*,
|
||||||
|
id: str = Path(..., title="ID of the tag"),
|
||||||
|
session: Session = Depends(database.get_session)
|
||||||
|
):
|
||||||
|
"""Delete a tag specified by name."""
|
||||||
|
|
||||||
|
tag = session.get(Tag, id)
|
||||||
|
if not tag:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
session.delete(tag)
|
||||||
|
session.commit()
|
||||||
|
# TODO: This method implicitly returns None, which will be converted to null by FastAPI, which triggers a
|
||||||
|
# warning, because the method returns with header HTTP_204_NO_CONTENT, but has content
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{id}")
|
||||||
|
async def patch_tag(
|
||||||
|
*,
|
||||||
|
tag: TagCreate,
|
||||||
|
id: str = Path(..., title="ID of the tag"),
|
||||||
|
session: Session = Depends(database.get_session)
|
||||||
|
):
|
||||||
|
"""Apply partial updates to a tag."""
|
||||||
|
|
||||||
|
# Fetch tag with id from database
|
||||||
|
db_tag = session.get(Tag, id)
|
||||||
|
if not db_tag:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# Set all provided arguments in the database tag
|
||||||
|
for key, value in tag.dict(exclude_unset=True).items():
|
||||||
|
setattr(db_tag, key, value)
|
||||||
|
|
||||||
|
# Write modified tag to database
|
||||||
|
session.add(db_tag)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_tag)
|
||||||
|
return db_tag
|
88
backend/src/routes/test_projects.py
Normal file
88
backend/src/routes/test_projects.py
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from fastapi import status
|
||||||
|
from sqlmodel import create_engine, Session, SQLModel, select
|
||||||
|
from sqlmodel.pool import StaticPool
|
||||||
|
|
||||||
|
from src import database
|
||||||
|
from src.main import app
|
||||||
|
from src.models.project import Project
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_create(client: TestClient):
|
||||||
|
project = {"name": "Uni"}
|
||||||
|
response = client.post("/projects/", json=project)
|
||||||
|
response_data: dict = response.json()
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
assert project.items() <= response_data.items() # Is subset, i.e. was all send data saved?
|
||||||
|
assert response_data["id"] # Not empty?
|
||||||
|
assert response_data["creation_date"] # Not empty?
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_get(client: TestClient, session: Session):
|
||||||
|
# Fill database
|
||||||
|
project = Project(name="Uni", creation_date=datetime.now())
|
||||||
|
session.add(project)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(project)
|
||||||
|
# Request data from API
|
||||||
|
response = client.get(f"/projects/{project.id}")
|
||||||
|
response_data = response.json()
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response_data["id"] == project.id
|
||||||
|
assert response_data["name"] == project.name
|
||||||
|
assert response_data["creation_date"] == project.creation_date.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_update(client: TestClient, session: Session):
|
||||||
|
# Fill database
|
||||||
|
project = Project(name="Uni", creation_date=datetime.now())
|
||||||
|
session.add(project)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(project)
|
||||||
|
# Perform update via API
|
||||||
|
project_updates = {"name": "UNI"}
|
||||||
|
response = client.patch(f"/projects/{project.id}", json=project_updates)
|
||||||
|
response_data = response.json()
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response_data["id"] == project.id
|
||||||
|
assert response_data["name"] == project_updates["name"]
|
||||||
|
assert response_data["creation_date"] == project.creation_date.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_delete(client: TestClient, session: Session):
|
||||||
|
# Fill database
|
||||||
|
project = Project(name="Uni", creation_date=datetime.now())
|
||||||
|
session.add(project)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(project)
|
||||||
|
# Delete via API
|
||||||
|
response = client.delete(f"/projects/{project.id}")
|
||||||
|
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||||
|
assert session.exec(select(Project).where(Project.name == "Uni")).first() is None # Element deleted from database?
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_get_list(client: TestClient, session: Session):
|
||||||
|
# Fill database
|
||||||
|
project_uni = Project(name="Uni", creation_date=datetime.now())
|
||||||
|
project_sport = Project(name="Sport", creation_date=datetime.now())
|
||||||
|
session.add(project_uni)
|
||||||
|
session.add(project_sport)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(project_uni)
|
||||||
|
session.refresh(project_sport)
|
||||||
|
# Request data from API
|
||||||
|
response = client.get("/projects/")
|
||||||
|
response_data = response.json()
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert len(response_data) == 2
|
||||||
|
|
||||||
|
assert response_data[0]["name"] == project_uni.name
|
||||||
|
assert response_data[0]["id"] == project_uni.id
|
||||||
|
assert response_data[0]["creation_date"] == project_uni.creation_date.isoformat()
|
||||||
|
|
||||||
|
assert response_data[1]["name"] == project_sport.name
|
||||||
|
assert response_data[1]["id"] == project_sport.id
|
||||||
|
assert response_data[1]["creation_date"] == project_sport.creation_date.isoformat()
|
80
backend/src/routes/test_tags.py
Normal file
80
backend/src/routes/test_tags.py
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from fastapi import status
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from src.models.tag import Tag
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_create(client: TestClient):
|
||||||
|
tag = {"name": "Uni"}
|
||||||
|
response = client.post("/tags/", json=tag)
|
||||||
|
response_data: dict = response.json()
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
assert tag.items() <= response_data.items() # Is subset, i.e. was all send data saved?
|
||||||
|
assert response_data["id"] # Not empty?
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_get(client: TestClient, session: Session):
|
||||||
|
# Fill database
|
||||||
|
tag = Tag(name="Uni")
|
||||||
|
session.add(tag)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(tag)
|
||||||
|
# Request data from API
|
||||||
|
print(f"get tag {tag.id}")
|
||||||
|
response = client.get(f"/tags/{tag.id}")
|
||||||
|
response_data = response.json()
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response_data["id"] == tag.id
|
||||||
|
assert response_data["name"] == tag.name
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_update(client: TestClient, session: Session):
|
||||||
|
# Fill database
|
||||||
|
tag = Tag(name="Uni")
|
||||||
|
session.add(tag)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(tag)
|
||||||
|
# Perform update via API
|
||||||
|
tag_updates = {"name": "UNI"}
|
||||||
|
response = client.patch(f"/tags/{tag.id}", json=tag_updates)
|
||||||
|
response_data = response.json()
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response_data["id"] == tag.id
|
||||||
|
assert response_data["name"] == tag_updates["name"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_delete(client: TestClient, session: Session):
|
||||||
|
# Fill database
|
||||||
|
tag = Tag(name="Uni")
|
||||||
|
session.add(tag)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(tag)
|
||||||
|
# Delete via API
|
||||||
|
response = client.delete(f"/tags/{tag.id}")
|
||||||
|
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||||
|
assert session.exec(select(Tag).where(Tag.name == "Uni")).first() is None # Element deleted from database?
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_get_list(client: TestClient, session: Session):
|
||||||
|
# Fill database
|
||||||
|
tag_uni = Tag(name="Uni", creation_date=datetime.now())
|
||||||
|
tag_sport = Tag(name="Sport", creation_date=datetime.now())
|
||||||
|
session.add(tag_uni)
|
||||||
|
session.add(tag_sport)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(tag_uni)
|
||||||
|
session.refresh(tag_sport)
|
||||||
|
# Request data from API
|
||||||
|
response = client.get("/tags/")
|
||||||
|
response_data = response.json()
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert len(response_data) == 2
|
||||||
|
|
||||||
|
assert response_data[0]["name"] == tag_uni.name
|
||||||
|
assert response_data[0]["id"] == tag_uni.id
|
||||||
|
|
||||||
|
assert response_data[1]["name"] == tag_sport.name
|
||||||
|
assert response_data[1]["id"] == tag_sport.id
|
|
@ -5,8 +5,8 @@ error_reporting(E_ALL | E_STRICT);
|
||||||
|
|
||||||
$config = [
|
$config = [
|
||||||
"host" => "localhost",
|
"host" => "localhost",
|
||||||
"dbname" => "admin_juggl",
|
"dbname" => "juggl",
|
||||||
"username" => "admin_juggl",
|
"username" => "juggl",
|
||||||
"password" => "}dyn{5O!tUlZD;9R?lbi$.@=I,_a2L",
|
"password" => "?=5,}f_F&){;@xthx-[i",
|
||||||
"table_prefix" => "ju_"
|
"table_prefix" => "ju_"
|
||||||
];
|
];
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
@ -1,12 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<b-table
|
<b-table
|
||||||
:items="projectStatistics"
|
:items="statistics"
|
||||||
primary-key="project_id"
|
primary-key="project_id"
|
||||||
hover
|
hover
|
||||||
:busy="isLoading"
|
:busy="isLoading"
|
||||||
:fields="statistic_fields"
|
:fields="statistic_fields"
|
||||||
sort-by="duration"
|
sort-by="name"
|
||||||
sort-desc
|
|
||||||
>
|
>
|
||||||
<template #table-busy>
|
<template #table-busy>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
|
@ -22,10 +21,6 @@
|
||||||
<template #cell(duration)="data">
|
<template #cell(duration)="data">
|
||||||
{{ getDurationTimestamp(data.item.duration) }}
|
{{ getDurationTimestamp(data.item.duration) }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell(distribution)="data">
|
|
||||||
{{ ((data.item.duration / totalDuration) * 100).toFixed(0) }}%
|
|
||||||
</template>
|
|
||||||
</b-table>
|
</b-table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -58,10 +53,6 @@ export default {
|
||||||
{
|
{
|
||||||
key: "record_count",
|
key: "record_count",
|
||||||
label: "Records"
|
label: "Records"
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "distribution",
|
|
||||||
label: "Distribution"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
@ -69,38 +60,6 @@ export default {
|
||||||
computed: {
|
computed: {
|
||||||
isLoading: function() {
|
isLoading: function() {
|
||||||
return this.statistics === undefined;
|
return this.statistics === undefined;
|
||||||
},
|
|
||||||
totalDuration: function() {
|
|
||||||
var duration = 0;
|
|
||||||
this.statistics.forEach(stat => (duration += Number(stat.duration)));
|
|
||||||
return duration;
|
|
||||||
},
|
|
||||||
projectStatistics: function() {
|
|
||||||
if (this.statistics === undefined) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
var projects = {};
|
|
||||||
this.statistics.forEach(stat => {
|
|
||||||
if (projects[stat.project_id] === undefined) {
|
|
||||||
projects[stat.project_id] = {
|
|
||||||
project_id: stat.project_id,
|
|
||||||
duration: 0,
|
|
||||||
record_count: 0,
|
|
||||||
color: stat.color,
|
|
||||||
name: stat.name,
|
|
||||||
visible: stat.visible,
|
|
||||||
statistics: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
projects[stat.project_id].duration += Number(stat.duration);
|
|
||||||
projects[stat.project_id].record_count += Number(stat.record_count);
|
|
||||||
projects[stat.project_id].statistics.push(stat);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simplyfying object to lists
|
|
||||||
return Object.values(projects);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
|
@ -64,17 +64,13 @@ export const helperService = {
|
||||||
* @returns Date as string in the used format
|
* @returns Date as string in the used format
|
||||||
*/
|
*/
|
||||||
toISODate(date) {
|
toISODate(date) {
|
||||||
return (
|
var timezoneOffset = date.getMinutes() + date.getTimezoneOffset();
|
||||||
date.getFullYear() +
|
var timestamp = date.getTime() + timezoneOffset * 1000;
|
||||||
"-" +
|
var correctDate = new Date(timestamp);
|
||||||
(date.getMonth() + 1).toString().padStart(2, "0") +
|
|
||||||
"-" +
|
correctDate.setUTCHours(0, 0, 0, 0);
|
||||||
date
|
|
||||||
.getDate()
|
return correctDate.toISOString();
|
||||||
.toString()
|
|
||||||
.padStart(2, "0") +
|
|
||||||
"T00:00.000Z"
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
|
@ -244,28 +244,18 @@ export const juggl = {
|
||||||
commit("setRecords", allRecords);
|
commit("setRecords", allRecords);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
loadDailyStatistics({ dispatch }, { date }) {
|
loadTodaysStatistics({ dispatch }) {
|
||||||
dispatch("loadStatistics", { from: date, until: date });
|
dispatch("loadStatistics", { from: new Date(), until: new Date() });
|
||||||
},
|
},
|
||||||
loadMonthlyStatistics(
|
loadStatistics({ commit }, { from, until }) {
|
||||||
{ dispatch },
|
|
||||||
{ startYear, startMonth, endYear, endMonth }
|
|
||||||
) {
|
|
||||||
|
|
||||||
// Month in date object goes from 0 - 11
|
|
||||||
var options = {
|
var options = {
|
||||||
from: new Date(startYear, startMonth - 1, 1),
|
from: from,
|
||||||
until: new Date(endYear, endMonth, 0) // 0 leads to the last day of the previous month
|
until: until
|
||||||
};
|
};
|
||||||
|
|
||||||
dispatch("loadStatistics", options);
|
return jugglService.getStatistics(options).then(r => {
|
||||||
},
|
commit("setStatistics", r.data.statistics);
|
||||||
async loadStatistics({ commit }, options) {
|
});
|
||||||
var results = Object.values(
|
|
||||||
(await jugglService.getStatistics(options)).data.statistics
|
|
||||||
);
|
|
||||||
|
|
||||||
commit("setStatistics", results);
|
|
||||||
},
|
},
|
||||||
loadRunningRecords({ commit, getters }) {
|
loadRunningRecords({ commit, getters }) {
|
||||||
return jugglService.getRunningRecords().then(r => {
|
return jugglService.getRunningRecords().then(r => {
|
56
frontend/src/views/Changelog.vue
Normal file
56
frontend/src/views/Changelog.vue
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
<template>
|
||||||
|
<LayoutMinimal title="Changelog">
|
||||||
|
<BaseTitle size="small">
|
||||||
|
23.11.2021
|
||||||
|
</BaseTitle>
|
||||||
|
<ul>
|
||||||
|
<li>Added tools page</li>
|
||||||
|
<li>Visual tweaks</li>
|
||||||
|
</ul>
|
||||||
|
<BaseTitle size="small">
|
||||||
|
07.11.2021
|
||||||
|
</BaseTitle>
|
||||||
|
<ul>
|
||||||
|
<li>Added credits page</li>
|
||||||
|
<li>Created basic footer</li>
|
||||||
|
<li>Added live record timer</li>
|
||||||
|
</ul>
|
||||||
|
<BaseTitle size="small">
|
||||||
|
27.07.2021
|
||||||
|
</BaseTitle>
|
||||||
|
<ul>
|
||||||
|
<li>Added simple statistics</li>
|
||||||
|
</ul>
|
||||||
|
<BaseTitle size="small">
|
||||||
|
21.05.2021
|
||||||
|
</BaseTitle>
|
||||||
|
<ul>
|
||||||
|
<li>Visual tweaks</li>
|
||||||
|
</ul>
|
||||||
|
<BaseTitle size="small">
|
||||||
|
12.04.2021
|
||||||
|
</BaseTitle>
|
||||||
|
<ul>
|
||||||
|
<li>Added toggle to change visibility of single projects and tags</li>
|
||||||
|
</ul>
|
||||||
|
</LayoutMinimal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// @ is an alias to /src
|
||||||
|
import LayoutMinimal from "@/components/layout/LayoutMinimal";
|
||||||
|
import BaseTitle from "@/components/base/BaseTitle";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "Changelog",
|
||||||
|
components: {
|
||||||
|
LayoutMinimal,
|
||||||
|
BaseTitle
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass" scoped>
|
||||||
|
ul
|
||||||
|
list-style-type: '+ '
|
||||||
|
</style>
|
47
frontend/src/views/Credits.vue
Normal file
47
frontend/src/views/Credits.vue
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
<template>
|
||||||
|
<LayoutMinimal title="Credits" >
|
||||||
|
<BaseTitle>
|
||||||
|
vue-timers
|
||||||
|
</BaseTitle>
|
||||||
|
<BaseTitle size="tiny">
|
||||||
|
<b-link href="https://github.com/Kelin2025/vue-timers">
|
||||||
|
<b-icon-github></b-icon-github>
|
||||||
|
GitHub
|
||||||
|
</b-link>
|
||||||
|
</BaseTitle>
|
||||||
|
<p class="monospace">
|
||||||
|
MIT License
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
Copyright (c) 2017 Anton Kosykh
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
</p>
|
||||||
|
</LayoutMinimal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// @ is an alias to /src
|
||||||
|
import LayoutMinimal from "@/components/layout/LayoutMinimal";
|
||||||
|
import BaseTitle from "@/components/base/BaseTitle";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "Credits",
|
||||||
|
components: {
|
||||||
|
LayoutMinimal,
|
||||||
|
BaseTitle
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass" scoped>
|
||||||
|
.monospace
|
||||||
|
font-family: Courier New, Courier, monospace
|
||||||
|
</style>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue