project: ruff check and format
This commit is contained in:
parent
0a9d941eea
commit
1e906fc3f0
27 changed files with 93 additions and 63 deletions
|
|
@ -2,4 +2,4 @@
|
||||||
Configurations for the <this> module
|
Configurations for the <this> module
|
||||||
|
|
||||||
Exports:
|
Exports:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,4 @@
|
||||||
Constants for the <this> module
|
Constants for the <this> module
|
||||||
|
|
||||||
Exports:
|
Exports:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,4 @@ Dependencies related to the <this> module
|
||||||
|
|
||||||
Exports:
|
Exports:
|
||||||
- <dep_name>: <return_type>: <description>
|
- <dep_name>: <return_type>: <description>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,4 @@ Exceptions related to the <this> modules
|
||||||
|
|
||||||
Exceptions:
|
Exceptions:
|
||||||
- <ExceptionName>: Details e.g. optional params
|
- <ExceptionName>: Details e.g. optional params
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,4 @@ Models:
|
||||||
- <normal_columns[FK][PK]>
|
- <normal_columns[FK][PK]>
|
||||||
- <orm_relationships>
|
- <orm_relationships>
|
||||||
- <calculated_properties>
|
- <calculated_properties>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ Exports:
|
||||||
- Dependencies should be used for db model get and validation where possible
|
- Dependencies should be used for db model get and validation where possible
|
||||||
- Verify module level docstring is still accurate after updates
|
- Verify module level docstring is still accurate after updates
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
|
|
@ -38,14 +39,16 @@ async def start_timer(request: Request, interval: int):
|
||||||
print("ping")
|
print("ping")
|
||||||
|
|
||||||
stop_event = threading.Event()
|
stop_event = threading.Event()
|
||||||
timer = create_timer(func=example_timer_target, interval=interval, stop_event=stop_event)
|
timer = create_timer(
|
||||||
|
func=example_timer_target, interval=interval, stop_event=stop_event
|
||||||
|
)
|
||||||
timer_ident = "example_timer"
|
timer_ident = "example_timer"
|
||||||
|
|
||||||
timer_tracker = {
|
timer_tracker = {
|
||||||
"ident": timer_ident,
|
"ident": timer_ident,
|
||||||
"interval": interval,
|
"interval": interval,
|
||||||
"stop_event": stop_event,
|
"stop_event": stop_event,
|
||||||
"timer": timer
|
"timer": timer,
|
||||||
}
|
}
|
||||||
timer.start()
|
timer.start()
|
||||||
|
|
||||||
|
|
@ -58,7 +61,10 @@ async def start_timer(request: Request, interval: int):
|
||||||
async def stop_timer(request: Request, ident: str):
|
async def stop_timer(request: Request, ident: str):
|
||||||
timers = request.app.state.timers
|
timers = request.app.state.timers
|
||||||
|
|
||||||
idx, timer_tracker = next(((i, timer) for i, timer in enumerate(timers) if timer["ident"] == ident), (None, None))
|
idx, timer_tracker = next(
|
||||||
|
((i, timer) for i, timer in enumerate(timers) if timer["ident"] == ident),
|
||||||
|
(None, None),
|
||||||
|
)
|
||||||
|
|
||||||
if not timer_tracker:
|
if not timer_tracker:
|
||||||
raise HTTPException(status_code=404, detail="Timer not found")
|
raise HTTPException(status_code=404, detail="Timer not found")
|
||||||
|
|
@ -72,9 +78,14 @@ async def stop_timer(request: Request, ident: str):
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hub/access")
|
@router.get("/hub/access")
|
||||||
async def test_hub_access(headers: header_dependency, client: http_client_dependency, org_name: Annotated[str, Query()],
|
async def test_hub_access(
|
||||||
resource_name: Annotated[str, Query()] = "example_resource",
|
headers: header_dependency,
|
||||||
action: Annotated[str, Query()] = "read", instance: Optional[Annotated[str, Query()]] = None):
|
client: http_client_dependency,
|
||||||
|
org_name: Annotated[str, Query()],
|
||||||
|
resource_name: Annotated[str, Query()] = "example_resource",
|
||||||
|
action: Annotated[str, Query()] = "read",
|
||||||
|
instance: Optional[Annotated[str, Query()]] = None,
|
||||||
|
):
|
||||||
rn = generate_resource_name(resource=resource_name, org=org_name, instance=instance)
|
rn = generate_resource_name(resource=resource_name, org=org_name, instance=instance)
|
||||||
|
|
||||||
request_body = {
|
request_body = {
|
||||||
|
|
@ -83,33 +94,28 @@ async def test_hub_access(headers: header_dependency, client: http_client_depend
|
||||||
}
|
}
|
||||||
|
|
||||||
hub_response = await client.post(
|
hub_response = await client.post(
|
||||||
f"http://localhost:8001/api/v1/iam/can_act_on_resource",
|
"http://localhost:8001/api/v1/iam/can_act_on_resource",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json=request_body
|
json=request_body,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"response": {
|
"response": {"status": hub_response.status_code, "json": hub_response.json()}
|
||||||
"status": hub_response.status_code,
|
|
||||||
"json": hub_response.json()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hub/user")
|
@router.get("/hub/user")
|
||||||
async def test_hub_user_details(headers: header_dependency, client: http_client_dependency):
|
async def test_hub_user_details(
|
||||||
|
headers: header_dependency, client: http_client_dependency
|
||||||
|
):
|
||||||
hub_response = await client.get(
|
hub_response = await client.get(
|
||||||
f"http://localhost:8001/api/v1/user/self/db",
|
"http://localhost:8001/api/v1/user/self/db", headers=headers
|
||||||
headers=headers
|
|
||||||
)
|
)
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"response": {
|
"response": {"status": hub_response.status_code, "json": hub_response.json()}
|
||||||
"status": hub_response.status_code,
|
|
||||||
"json": hub_response.json()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,4 @@ Models follow the nomenclature of:
|
||||||
- Sub-models: "<Resource><Opt:>Schema"
|
- Sub-models: "<Resource><Opt:>Schema"
|
||||||
- Mixins: "<Attribute>Mixin"
|
- Mixins: "<Attribute>Mixin"
|
||||||
- Models: "<Module><Method><Resource><Opt:Resource><Direction>" ie ""
|
- Models: "<Module><Method><Resource><Opt:Resource><Direction>" ie ""
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,4 @@
|
||||||
Module specific business logic for the <this> module
|
Module specific business logic for the <this> module
|
||||||
|
|
||||||
Exports:
|
Exports:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
"""
|
"""
|
||||||
Non-business logic reusable functions and classes for the <this> module
|
Non-business logic reusable functions and classes for the <this> module
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
"""
|
"""
|
||||||
This module hooks the routers for the main endpoints into a single router for importing to the app.
|
This module hooks the routers for the main endpoints into a single router for importing to the app.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from src.auth.router import router as auth_router
|
from src.auth.router import router as auth_router
|
||||||
from src._module_template.router import router as template_router
|
from src._module_template.router import router as template_router
|
||||||
|
|
||||||
|
|
||||||
api_router = APIRouter(
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
prefix="/api/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
api_router.include_router(auth_router)
|
api_router.include_router(auth_router)
|
||||||
api_router.include_router(template_router)
|
api_router.include_router(template_router)
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,15 @@ Configurations for the auth module
|
||||||
Exports:
|
Exports:
|
||||||
- auth_settings: Contains OIDC & hub access information
|
- auth_settings: Contains OIDC & hub access information
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from src.config import CustomBaseSettings
|
from src.config import CustomBaseSettings
|
||||||
|
|
||||||
|
|
||||||
class AuthConfig(CustomBaseSettings):
|
class AuthConfig(CustomBaseSettings):
|
||||||
OIDC_CONFIG: str = ""
|
OIDC_CONFIG: str = ""
|
||||||
CLIENT_ID: str = ""
|
CLIENT_ID: str = ""
|
||||||
|
|
||||||
HUB_ACCESS_KEY: str = ""
|
HUB_ACCESS_KEY: str = ""
|
||||||
|
|
||||||
|
|
||||||
auth_settings = AuthConfig()
|
auth_settings = AuthConfig()
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
"""
|
"""
|
||||||
Constants for the auth module
|
Constants for the auth module
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ Auth dependencies
|
||||||
|
|
||||||
Exports:
|
Exports:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ Module specific exceptions for the auth module
|
||||||
Exceptions:
|
Exceptions:
|
||||||
- UnauthorizedException: Takes an optional message string
|
- UnauthorizedException: Takes an optional message string
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
"""
|
"""
|
||||||
Database models for the auth module
|
Database models for the auth module
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ Router endpoints for the auth module
|
||||||
Exports:
|
Exports:
|
||||||
- router: fastapi.APIRouter
|
- router: fastapi.APIRouter
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
tags=["auth"],
|
tags=["auth"],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
"""
|
"""
|
||||||
Pydantic models for the auth module
|
Pydantic models for the auth module
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
"""
|
"""
|
||||||
Non-business logic reusable functions and classes for the auth module
|
Non-business logic reusable functions and classes for the auth module
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -16,27 +16,28 @@ from src.constants import Environment
|
||||||
|
|
||||||
|
|
||||||
class CustomBaseSettings(BaseSettings):
|
class CustomBaseSettings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Config(CustomBaseSettings):
|
class Config(CustomBaseSettings):
|
||||||
APP_VERSION: str = "0.1"
|
APP_VERSION: str = "0.1"
|
||||||
ENVIRONMENT: Environment = Environment.PRODUCTION
|
ENVIRONMENT: Environment = Environment.PRODUCTION
|
||||||
SECRET_KEY: SecretStr = ""
|
SECRET_KEY: SecretStr = ""
|
||||||
DISABLE_AUTH: bool = False
|
DISABLE_AUTH: bool = False
|
||||||
SERVICE_NAME: str = "template_app"
|
SERVICE_NAME: str = "template_app"
|
||||||
HUB_ADDRESS: str = "http://localhost:8000"
|
HUB_ADDRESS: str = "http://localhost:8000"
|
||||||
|
|
||||||
CORS_ORIGINS: list[str] = ["*"]
|
CORS_ORIGINS: list[str] = ["*"]
|
||||||
CORS_ORIGINS_REGEX: str | None = None
|
CORS_ORIGINS_REGEX: str | None = None
|
||||||
CORS_HEADERS: list[str] = ["*"]
|
CORS_HEADERS: list[str] = ["*"]
|
||||||
|
|
||||||
|
DATABASE_NAME: str = "fastapi-exp"
|
||||||
|
DATABASE_PORT: str = "5432"
|
||||||
|
DATABASE_HOSTNAME: str = "localhost"
|
||||||
|
DATABASE_CREDENTIALS: SecretStr = ""
|
||||||
|
|
||||||
DATABASE_NAME: str = "fastapi-exp"
|
|
||||||
DATABASE_PORT: str = "5432"
|
|
||||||
DATABASE_HOSTNAME: str = "localhost"
|
|
||||||
DATABASE_CREDENTIALS: SecretStr = ""
|
|
||||||
|
|
||||||
settings = Config()
|
settings = Config()
|
||||||
|
|
||||||
|
|
@ -45,17 +46,21 @@ DATABASE_PORT = settings.DATABASE_PORT
|
||||||
DATABASE_HOSTNAME = settings.DATABASE_HOSTNAME
|
DATABASE_HOSTNAME = settings.DATABASE_HOSTNAME
|
||||||
DATABASE_CREDENTIALS = settings.DATABASE_CREDENTIALS.get_secret_value()
|
DATABASE_CREDENTIALS = settings.DATABASE_CREDENTIALS.get_secret_value()
|
||||||
# this will support special chars for credentials
|
# this will support special chars for credentials
|
||||||
_DATABASE_CREDENTIAL_USER, _DATABASE_CREDENTIAL_PASSWORD = str(DATABASE_CREDENTIALS).split(":")
|
_DATABASE_CREDENTIAL_USER, _DATABASE_CREDENTIAL_PASSWORD = str(
|
||||||
|
DATABASE_CREDENTIALS
|
||||||
|
).split(":")
|
||||||
_QUOTED_DATABASE_PASSWORD = parse.quote_plus(str(_DATABASE_CREDENTIAL_PASSWORD))
|
_QUOTED_DATABASE_PASSWORD = parse.quote_plus(str(_DATABASE_CREDENTIAL_PASSWORD))
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URI = SecretStr(f"postgresql+psycopg://{_DATABASE_CREDENTIAL_USER}:{_QUOTED_DATABASE_PASSWORD}@{DATABASE_HOSTNAME}:{DATABASE_PORT}/{DATABASE_NAME}")
|
SQLALCHEMY_DATABASE_URI = SecretStr(
|
||||||
|
f"postgresql+psycopg://{_DATABASE_CREDENTIAL_USER}:{_QUOTED_DATABASE_PASSWORD}@{DATABASE_HOSTNAME}:{DATABASE_PORT}/{DATABASE_NAME}"
|
||||||
|
)
|
||||||
|
|
||||||
if settings.ENVIRONMENT == Environment.TESTING:
|
if settings.ENVIRONMENT == Environment.TESTING:
|
||||||
SQLALCHEMY_DATABASE_URI = SecretStr("sqlite:///:memory:")
|
SQLALCHEMY_DATABASE_URI = SecretStr("sqlite:///:memory:")
|
||||||
|
|
||||||
app_configs: dict[str, Any] = {"title": "App API"}
|
app_configs: dict[str, Any] = {"title": "App API"}
|
||||||
if settings.ENVIRONMENT.is_deployed:
|
if settings.ENVIRONMENT.is_deployed:
|
||||||
app_configs["root_path"] = f"/v{settings.APP_VERSION}"
|
app_configs["root_path"] = f"/v{settings.APP_VERSION}"
|
||||||
|
|
||||||
if not settings.ENVIRONMENT.is_debug:
|
if not settings.ENVIRONMENT.is_debug:
|
||||||
app_configs["openapi_url"] = None # hide docs
|
app_configs["openapi_url"] = None # hide docs
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ Global constants
|
||||||
Exports:
|
Exports:
|
||||||
- Environment(StrEnum): LOCAL, TESTING, STAGING, PRODUCTION
|
- Environment(StrEnum): LOCAL, TESTING, STAGING, PRODUCTION
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from enum import StrEnum, auto
|
from enum import StrEnum, auto
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Exports:
|
||||||
- db_dependency
|
- db_dependency
|
||||||
- Base (sqlalchemy base model)
|
- Base (sqlalchemy base model)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from sqlalchemy import create_engine, StaticPool
|
from sqlalchemy import create_engine, StaticPool
|
||||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session
|
||||||
|
|
@ -16,7 +17,11 @@ from src.config import SQLALCHEMY_DATABASE_URI, settings as global_settings
|
||||||
|
|
||||||
if global_settings.ENVIRONMENT == Environment.TESTING:
|
if global_settings.ENVIRONMENT == Environment.TESTING:
|
||||||
connect_args = {"check_same_thread": False}
|
connect_args = {"check_same_thread": False}
|
||||||
engine = create_engine(SQLALCHEMY_DATABASE_URI.get_secret_value(), connect_args=connect_args, poolclass=StaticPool)
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URI.get_secret_value(),
|
||||||
|
connect_args=connect_args,
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
engine = create_engine(SQLALCHEMY_DATABASE_URI.get_secret_value())
|
engine = create_engine(SQLALCHEMY_DATABASE_URI.get_secret_value())
|
||||||
|
|
||||||
|
|
@ -36,5 +41,7 @@ def get_db():
|
||||||
|
|
||||||
|
|
||||||
db_dependency = Annotated[Session, Depends(get_db)]
|
db_dependency = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,16 @@ Global dependencies
|
||||||
|
|
||||||
Exports:
|
Exports:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
|
||||||
|
|
||||||
async def create_http_client():
|
async def create_http_client():
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
yield client
|
yield client
|
||||||
|
|
||||||
|
|
||||||
http_client_dependency = Annotated[httpx.AsyncClient, Depends(create_http_client)]
|
http_client_dependency = Annotated[httpx.AsyncClient, Depends(create_http_client)]
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Exports:
|
||||||
- UnprocessableContentException
|
- UnprocessableContentException
|
||||||
- ConflictException
|
- ConflictException
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ Application root file: Inits the FastAPI application
|
||||||
Prometheus client mounted at /metrics endpoint
|
Prometheus client mounted at /metrics endpoint
|
||||||
Middleware: Session, CORS
|
Middleware: Session, CORS
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
from prometheus_client import make_asgi_app
|
from prometheus_client import make_asgi_app
|
||||||
|
|
@ -44,7 +45,7 @@ app = FastAPI(
|
||||||
"scopes": "openid profile email",
|
"scopes": "openid profile email",
|
||||||
},
|
},
|
||||||
openapi_tags=tags_metadata,
|
openapi_tags=tags_metadata,
|
||||||
**app_configs
|
**app_configs,
|
||||||
)
|
)
|
||||||
|
|
||||||
metrics_app = make_asgi_app()
|
metrics_app = make_asgi_app()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
"""
|
"""
|
||||||
Global database models
|
Global database models
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Exports:
|
||||||
- CustomBaseModel: Schema used for all other Pydantic models
|
- CustomBaseModel: Schema used for all other Pydantic models
|
||||||
- ResourceName
|
- ResourceName
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ Global non-business-logic reusable functions and classes
|
||||||
|
|
||||||
Exports:
|
Exports:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
|
@ -18,7 +19,9 @@ def create_timer(func: Callable, interval: int, stop_event: threading.Event):
|
||||||
return threading.Thread(target=target, daemon=True)
|
return threading.Thread(target=target, daemon=True)
|
||||||
|
|
||||||
|
|
||||||
def generate_resource_name(org: str, resource: str, instance: Optional[str] = None) -> ResourceName:
|
def generate_resource_name(
|
||||||
|
org: str, resource: str, instance: Optional[str] = None
|
||||||
|
) -> ResourceName:
|
||||||
rn = ResourceName(
|
rn = ResourceName(
|
||||||
service=settings.SERVICE_NAME,
|
service=settings.SERVICE_NAME,
|
||||||
organisation=org,
|
organisation=org,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue