Compare commits

...

33 commits

Author SHA1 Message Date
linuskmr
426d34a2d3 [WIP] All get, post, update, delete operations with relations working
However, all models are contained in one file for easier debugging. Trying to refactor this back to multiple files
2021-12-25 19:59:51 +01:00
linuskmr
2255f9edcd Add foreign keys and link for many-to-many relationship 2021-12-25 19:24:43 +01:00
linuskmr
96573e7d48 Implement record fetching from database 2021-12-25 18:59:36 +01:00
linuskmr
113d35201b Fix typo in documentation comment 2021-12-25 18:59:18 +01:00
linuskmr
5c75a71609 Re-designed record models 2021-12-25 18:58:56 +01:00
linuskmr
a7c3f6dfc9 Fix typo in documentation comment 2021-12-25 18:58:13 +01:00
linuskmr
abf61d651c Adapt tests for project and tag to fetching via id 2021-12-25 18:47:27 +01:00
linuskmr
f20423280b Fix wrong parameter name for id in get_tag() 2021-12-25 18:46:53 +01:00
linuskmr
6bcdda506a Tags get searched by id instead of by name 2021-12-25 18:35:15 +01:00
linuskmr
15ef4c03d1 Remove unnecessary summary for add project 2021-12-25 18:34:56 +01:00
linuskmr
8b79f7fca4 Projects get searched by id instead of by name 2021-12-25 18:32:16 +01:00
linuskmr
e70436104b Fix not matching documentation comments 2021-12-23 18:35:25 +01:00
linuskmr
b382beb5a6 Add unit test for tag router 2021-12-23 18:28:07 +01:00
linuskmr
b9996a9fe6 Move fixtures to conftest.py 2021-12-23 18:27:49 +01:00
linuskmr
0de01ac270 Fetch real data from database for tags 2021-12-23 18:27:17 +01:00
linuskmr
4fb5285c66 Temporary remove duration from Tag 2021-12-23 18:26:54 +01:00
linuskmr
17a72ecdfc Add SQLAlchemy and pytest as transitive dependencies 2021-12-23 18:26:12 +01:00
linuskmr
822a041abe Add comments to projects.py 2021-12-23 18:25:48 +01:00
linuskmr
fc08032f0a Add conftest.py for globally used fixtures 2021-12-23 18:25:32 +01:00
linuskmr
dde11213c0 Add requests as dependency because fastapi's TestClient needs it 2021-12-23 18:09:26 +01:00
linuskmr
a58924c6da Fix installing dependencies from requirements.txt instead of backend/requirements.txt 2021-12-23 18:06:52 +01:00
linuskmr
f9777a9274 Activate CI for all branches and pull requests 2021-12-23 18:04:58 +01:00
linuskmr
944a75f51a Add automatic testing CI 2021-12-23 18:04:05 +01:00
linuskmr
678bfa9736 Add documentation for testing 2021-12-23 18:00:06 +01:00
linuskmr
4d5875a3aa Add unit tests 2021-12-23 17:58:44 +01:00
linuskmr
23aa9a905b Fetch real data from database 2021-12-23 17:58:36 +01:00
linuskmr
5c717d4ee9 Delete duration and records from ProjectRead 2021-12-23 17:58:17 +01:00
linuskmr
cd2b0c0ca1 Add gzip compression middleware 2021-12-23 17:57:44 +01:00
linuskmr
3b50678d0a Add database engine and session creation 2021-12-22 12:10:08 +01:00
linuskmr
d381888f42 Add some SQL classes 2021-12-22 09:06:08 +01:00
linuskmr
906b8705b9 Add documentation 2021-12-21 22:45:28 +01:00
linuskmr
e45bf760b6 Add demo fastapi routes and models 2021-12-21 21:00:58 +01:00
3987c69a25 First structural change 2021-12-06 17:40:51 +01:00
106 changed files with 1171 additions and 13452 deletions

36
.github/workflows/python-app.yml vendored Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,7 @@
.PHONY = default test
default:
@echo "Please specify a make target"
test:
pytest

11
backend/README.md Normal file
View 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)

View file

@ -0,0 +1,7 @@
version: "3.9"
services:
api:
build: .
restart: always
ports:
- "8192:8192"

View 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...

View 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
View 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
View 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
View file

29
backend/src/database.py Normal file
View 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
View 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()

View file

View 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

View file

View 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()

View 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

View 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

View 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

View 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()

View 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

View file

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

View file

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View file

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Some files were not shown because too many files have changed in this diff Show more