Merge pull request 'Changed to asgi' (#14) from dev_asgi into dev
Reviewed-on: #14 Closes #13
This commit is contained in:
commit
1eb878477d
29
.gitea/workflows/test_api_before_merge.yaml
Normal file
29
.gitea/workflows/test_api_before_merge.yaml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
name: Test before pr merge
|
||||||
|
run-name: Test before pr merge
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
- reopened
|
||||||
|
- synchronize
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-lint:
|
||||||
|
runs-on: [ runner ]
|
||||||
|
container: git.sh-edraft.de/sh-edraft.de/act-runner:latest
|
||||||
|
steps:
|
||||||
|
- name: Clone Repository
|
||||||
|
uses: https://github.com/actions/checkout@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CI_ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- name: Installing dependencies
|
||||||
|
working-directory: ./api
|
||||||
|
run: |
|
||||||
|
python3.12 -m pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Checking black
|
||||||
|
working-directory: ./api
|
||||||
|
run: python3.12 -m black src --check
|
@ -1,39 +0,0 @@
|
|||||||
name: Test before pr merge
|
|
||||||
run-name: Test before pr merge
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- edited
|
|
||||||
- reopened
|
|
||||||
- synchronize
|
|
||||||
- ready_for_review
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test-before-merge:
|
|
||||||
runs-on: [ runner ]
|
|
||||||
container: git.sh-edraft.de/sh-edraft.de/act-runner:latest
|
|
||||||
steps:
|
|
||||||
- name: Clone Repository
|
|
||||||
uses: https://github.com/actions/checkout@v3
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.CI_ACCESS_TOKEN }}
|
|
||||||
|
|
||||||
- name: Setup node
|
|
||||||
uses: https://github.com/actions/setup-node@v3
|
|
||||||
|
|
||||||
- name: Installing dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Checking eslint
|
|
||||||
run: npm run lint
|
|
||||||
|
|
||||||
- name: Setup chrome
|
|
||||||
run: |
|
|
||||||
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
|
|
||||||
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y google-chrome-stable xvfb
|
|
||||||
|
|
||||||
- name: Testing
|
|
||||||
run: npm run test:ci
|
|
79
.gitea/workflows/test_web_before_merge.yaml
Normal file
79
.gitea/workflows/test_web_before_merge.yaml
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
name: Test before pr merge
|
||||||
|
run-name: Test before pr merge
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
- reopened
|
||||||
|
- synchronize
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-lint:
|
||||||
|
runs-on: [ runner ]
|
||||||
|
container: git.sh-edraft.de/sh-edraft.de/act-runner:latest
|
||||||
|
steps:
|
||||||
|
- name: Clone Repository
|
||||||
|
uses: https://github.com/actions/checkout@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CI_ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup node
|
||||||
|
uses: https://github.com/actions/setup-node@v3
|
||||||
|
|
||||||
|
- name: Installing dependencies
|
||||||
|
working-directory: ./web
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Checking eslint
|
||||||
|
working-directory: ./web
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
test-translation-lint:
|
||||||
|
runs-on: [ runner ]
|
||||||
|
container: git.sh-edraft.de/sh-edraft.de/act-runner:latest
|
||||||
|
steps:
|
||||||
|
- name: Clone Repository
|
||||||
|
uses: https://github.com/actions/checkout@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CI_ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup node
|
||||||
|
uses: https://github.com/actions/setup-node@v3
|
||||||
|
|
||||||
|
- name: Installing dependencies
|
||||||
|
working-directory: ./web
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Checking translations
|
||||||
|
working-directory: ./web
|
||||||
|
run: npm run lint:translations
|
||||||
|
|
||||||
|
test-before-merge:
|
||||||
|
runs-on: [ runner ]
|
||||||
|
container: git.sh-edraft.de/sh-edraft.de/act-runner:latest
|
||||||
|
steps:
|
||||||
|
- name: Clone Repository
|
||||||
|
uses: https://github.com/actions/checkout@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CI_ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup node
|
||||||
|
uses: https://github.com/actions/setup-node@v3
|
||||||
|
|
||||||
|
- name: Installing dependencies
|
||||||
|
working-directory: ./web
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Setup chrome
|
||||||
|
working-directory: ./web
|
||||||
|
run: |
|
||||||
|
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
|
||||||
|
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y google-chrome-stable xvfb
|
||||||
|
|
||||||
|
- name: Testing
|
||||||
|
working-directory: ./web
|
||||||
|
run: npm run test:ci
|
@ -1,10 +1,12 @@
|
|||||||
ariadne==0.23.0
|
ariadne==0.23.0
|
||||||
eventlet==0.37.0
|
broadcaster==0.3.1
|
||||||
graphql-core==3.2.5
|
graphql-core==3.2.5
|
||||||
Flask[async]==3.1.0
|
|
||||||
Flask-Cors==5.0.0
|
|
||||||
async-property==0.2.2
|
async-property==0.2.2
|
||||||
python-keycloak==4.7.3
|
|
||||||
psycopg[binary]==3.2.3
|
psycopg[binary]==3.2.3
|
||||||
psycopg-pool==3.2.4
|
psycopg-pool==3.2.4
|
||||||
Werkzeug==3.1.3
|
uvicorn==0.34.0
|
||||||
|
starlette==0.46.0
|
||||||
|
requests==2.32.3
|
||||||
|
python-keycloak==5.3.1
|
||||||
|
python-multipart==0.0.20
|
||||||
|
websockets==15.0
|
||||||
|
@ -1,78 +1,45 @@
|
|||||||
import importlib
|
import importlib
|
||||||
import os
|
import os
|
||||||
import time
|
from typing import Optional
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from flask import Flask, request, g
|
from starlette.applications import Starlette
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
from api.route import Route
|
from core.environment import Environment
|
||||||
from core.logger import APILogger
|
from core.logger import APILogger
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
logger = APILogger(__name__)
|
logger = APILogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def filter_relevant_headers(headers: dict) -> dict:
|
class API:
|
||||||
relevant_keys = {
|
app: Optional[Starlette] = None
|
||||||
"Content-Type",
|
|
||||||
"Host",
|
|
||||||
"Connection",
|
|
||||||
"User-Agent",
|
|
||||||
"Origin",
|
|
||||||
"Referer",
|
|
||||||
"Accept",
|
|
||||||
}
|
|
||||||
return {key: value for key, value in headers.items() if key in relevant_keys}
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, app: Starlette):
|
||||||
|
cls.app = app
|
||||||
|
|
||||||
@app.before_request
|
@staticmethod
|
||||||
async def log_request():
|
async def handle_exception(request: Request, exc: Exception):
|
||||||
g.request_id = uuid4()
|
logger.error(f"Request {request.state.request_id}", exc)
|
||||||
g.start_time = time.time()
|
return JSONResponse({"error": str(exc)}, status_code=500)
|
||||||
logger.debug(
|
|
||||||
f"Request {g.request_id}: {request.method}@{request.path} from {request.remote_addr}"
|
|
||||||
)
|
|
||||||
user = await Route.get_user()
|
|
||||||
|
|
||||||
request_info = {
|
@staticmethod
|
||||||
"headers": filter_relevant_headers(dict(request.headers)),
|
def get_allowed_origins():
|
||||||
"args": request.args.to_dict(),
|
client_urls = Environment.get("CLIENT_URLS", str)
|
||||||
"form-data": request.form.to_dict(),
|
if client_urls is None or client_urls == "":
|
||||||
"payload": request.get_json(silent=True),
|
allowed_origins = ["*"]
|
||||||
"user": f"{user.id}-{user.keycloak_id}" if user else None,
|
logger.warning("No allowed origins specified, allowing all origins")
|
||||||
"files": (
|
else:
|
||||||
{key: file.filename for key, file in request.files.items()}
|
allowed_origins = client_urls.split(",")
|
||||||
if request.files
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.trace(f"Request {g.request_id}: {request_info}")
|
return allowed_origins
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
@app.after_request
|
def import_routes():
|
||||||
def log_after_request(response):
|
# used to import all routes
|
||||||
# calc the time it took to process the request
|
routes_dir = os.path.join(os.path.dirname(__file__), "routes")
|
||||||
duration = (time.time() - g.start_time) * 1000
|
for filename in os.listdir(routes_dir):
|
||||||
logger.info(
|
if filename.endswith(".py") and filename != "__init__.py":
|
||||||
f"Request finished {g.request_id}: {response.status_code}-{request.method}@{request.path} from {request.remote_addr} in {duration:.2f}ms"
|
module_name = f"api.routes.{filename[:-3]}"
|
||||||
)
|
importlib.import_module(module_name)
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(Exception)
|
|
||||||
def handle_exception(e):
|
|
||||||
logger.error(f"Request {g.request_id}", e)
|
|
||||||
return {"error": str(e)}, 500
|
|
||||||
|
|
||||||
|
|
||||||
# used to import all routes
|
|
||||||
routes_dir = os.path.join(os.path.dirname(__file__), "routes")
|
|
||||||
for filename in os.listdir(routes_dir):
|
|
||||||
if filename.endswith(".py") and filename != "__init__.py":
|
|
||||||
module_name = f"api.routes.{filename[:-3]}"
|
|
||||||
importlib.import_module(module_name)
|
|
||||||
|
|
||||||
# Explicitly register the routes
|
|
||||||
for route, (view_func, options) in Route.registered_routes.items():
|
|
||||||
app.add_url_rule(route, view_func=view_func, **options)
|
|
||||||
|
5
api/src/api/broadcast.py
Normal file
5
api/src/api/broadcast.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from broadcaster import Broadcast
|
||||||
|
|
||||||
|
broadcast: Optional[Broadcast] = Broadcast("memory://")
|
@ -1,9 +1,9 @@
|
|||||||
from flask import jsonify
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
|
||||||
def unauthorized():
|
def unauthorized():
|
||||||
return jsonify({"error": "Unauthorized"}), 401
|
return JSONResponse({"error": "Unauthorized"}, 401)
|
||||||
|
|
||||||
|
|
||||||
def forbidden():
|
def forbidden():
|
||||||
return jsonify({"error": "Unauthorized"}), 401
|
return JSONResponse({"error": "Unauthorized"}, 401)
|
||||||
|
0
api/src/api/middleware/__init__.py
Normal file
0
api/src/api/middleware/__init__.py
Normal file
73
api/src/api/middleware/logging.py
Normal file
73
api/src/api/middleware/logging.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import time
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from api.route import Route
|
||||||
|
from core.logger import APILogger
|
||||||
|
|
||||||
|
logger = APILogger("api.api")
|
||||||
|
|
||||||
|
|
||||||
|
class LoggingMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
await self._log_request(request)
|
||||||
|
response = await call_next(request)
|
||||||
|
await self._log_after_request(request, response)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _filter_relevant_headers(headers: dict) -> dict:
|
||||||
|
relevant_keys = {
|
||||||
|
"content-type",
|
||||||
|
"host",
|
||||||
|
"connection",
|
||||||
|
"user-agent",
|
||||||
|
"origin",
|
||||||
|
"referer",
|
||||||
|
"accept",
|
||||||
|
}
|
||||||
|
return {key: value for key, value in headers.items() if key in relevant_keys}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _log_request(cls, request: Request):
|
||||||
|
request.state.request_id = uuid4()
|
||||||
|
request.state.start_time = time.time()
|
||||||
|
logger.debug(
|
||||||
|
f"Request {request.state.request_id}: {request.method}@{request.url.path} from {request.client.host}"
|
||||||
|
)
|
||||||
|
user = await Route.get_user()
|
||||||
|
|
||||||
|
request_info = {
|
||||||
|
"headers": cls._filter_relevant_headers(dict(request.headers)),
|
||||||
|
"args": dict(request.query_params),
|
||||||
|
"form-data": (
|
||||||
|
await request.form()
|
||||||
|
if request.headers.get("content-type")
|
||||||
|
== "application/x-www-form-urlencoded"
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"payload": (
|
||||||
|
await request.json()
|
||||||
|
if request.headers.get("content-length") == "0"
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"user": f"{user.id}-{user.keycloak_id}" if user else None,
|
||||||
|
"files": (
|
||||||
|
{key: file.filename for key, file in (await request.form()).items()}
|
||||||
|
if await request.form()
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.trace(f"Request {request.state.request_id}: {request_info}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _log_after_request(request: Request, response: Response):
|
||||||
|
duration = (time.time() - request.state.start_time) * 1000
|
||||||
|
logger.info(
|
||||||
|
f"Request finished {request.state.request_id}: {response.status_code}-{request.method}@{request.url.path} from {request.client.host} in {duration:.2f}ms"
|
||||||
|
)
|
28
api/src/api/middleware/request.py
Normal file
28
api/src/api/middleware/request.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from contextvars import ContextVar
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.websockets import WebSocket
|
||||||
|
|
||||||
|
_request_context: ContextVar[Union[Request, None]] = ContextVar("request", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
_request_context.set(request)
|
||||||
|
|
||||||
|
from core.logger import APILogger
|
||||||
|
|
||||||
|
logger = APILogger(__name__)
|
||||||
|
logger.trace("Set new current request")
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def set_request(request: Union[Request, WebSocket, None]):
|
||||||
|
_request_context.set(request)
|
||||||
|
|
||||||
|
|
||||||
|
def get_request() -> Optional[Request]:
|
||||||
|
return _request_context.get()
|
26
api/src/api/middleware/websocket.py
Normal file
26
api/src/api/middleware/websocket.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
from ariadne.asgi.handlers import GraphQLTransportWSHandler
|
||||||
|
from starlette.datastructures import MutableHeaders
|
||||||
|
from starlette.websockets import WebSocket
|
||||||
|
|
||||||
|
from api.middleware.request import set_request
|
||||||
|
from core.logger import APILogger
|
||||||
|
|
||||||
|
logger = APILogger("WS")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticatedGraphQLTransportWSHandler(GraphQLTransportWSHandler):
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, on_connect=self.on_connect, **kwargs)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def on_connect(ws: WebSocket, message: dict):
|
||||||
|
if "Authorization" not in message:
|
||||||
|
return True
|
||||||
|
|
||||||
|
mutable_headers = MutableHeaders()
|
||||||
|
mutable_headers["Authorization"] = message.get("Authorization", "")
|
||||||
|
ws._headers = mutable_headers
|
||||||
|
|
||||||
|
set_request(ws)
|
||||||
|
return True
|
@ -2,12 +2,12 @@ import functools
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from inspect import iscoroutinefunction
|
from inspect import iscoroutinefunction
|
||||||
from typing import Callable, Union, Optional
|
from typing import Callable, Union, Optional
|
||||||
from urllib.request import Request
|
|
||||||
|
|
||||||
from flask import request
|
from starlette.requests import Request
|
||||||
from flask_cors import cross_origin
|
from starlette.routing import Route as StarletteRoute
|
||||||
|
|
||||||
from api.errors import unauthorized
|
from api.errors import unauthorized
|
||||||
|
from api.middleware.request import get_request
|
||||||
from api.route_user_extension import RouteUserExtension
|
from api.route_user_extension import RouteUserExtension
|
||||||
from core.environment import Environment
|
from core.environment import Environment
|
||||||
from data.schemas.administration.api_key import ApiKey
|
from data.schemas.administration.api_key import ApiKey
|
||||||
@ -16,10 +16,10 @@ from data.schemas.administration.user import User
|
|||||||
|
|
||||||
|
|
||||||
class Route(RouteUserExtension):
|
class Route(RouteUserExtension):
|
||||||
registered_routes = {}
|
registered_routes: list[StarletteRoute] = []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_api_key(cls) -> ApiKey:
|
async def get_api_key(cls, request: Request) -> ApiKey:
|
||||||
auth_header = request.headers.get("Authorization", None)
|
auth_header = request.headers.get("Authorization", None)
|
||||||
api_key = auth_header.split(" ")[1]
|
api_key = auth_header.split(" ")[1]
|
||||||
return await apiKeyDao.find_by_key(api_key)
|
return await apiKeyDao.find_by_key(api_key)
|
||||||
@ -35,11 +35,13 @@ class Route(RouteUserExtension):
|
|||||||
return api_key_from_db is not None and not api_key_from_db.deleted
|
return api_key_from_db is not None and not api_key_from_db.deleted
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _get_auth_type(cls, auth_header: str) -> Optional[Union[User, ApiKey]]:
|
async def _get_auth_type(
|
||||||
|
cls, request: Request, auth_header: str
|
||||||
|
) -> Optional[Union[User, ApiKey]]:
|
||||||
if auth_header.startswith("Bearer "):
|
if auth_header.startswith("Bearer "):
|
||||||
return await cls.get_user()
|
return await cls.get_user()
|
||||||
elif auth_header.startswith("API-Key "):
|
elif auth_header.startswith("API-Key "):
|
||||||
return await cls.get_api_key()
|
return await cls.get_api_key(request)
|
||||||
elif (
|
elif (
|
||||||
auth_header.startswith("DEV-User ")
|
auth_header.startswith("DEV-User ")
|
||||||
and Environment.get_environment() == "development"
|
and Environment.get_environment() == "development"
|
||||||
@ -49,11 +51,15 @@ class Route(RouteUserExtension):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_authenticated_user_or_api_key(cls) -> Union[User, ApiKey]:
|
async def get_authenticated_user_or_api_key(cls) -> Union[User, ApiKey]:
|
||||||
|
request = get_request()
|
||||||
|
if request is None:
|
||||||
|
raise ValueError("No request found")
|
||||||
|
|
||||||
auth_header = request.headers.get("Authorization", None)
|
auth_header = request.headers.get("Authorization", None)
|
||||||
if not auth_header:
|
if not auth_header:
|
||||||
raise Exception("No Authorization header found")
|
raise Exception("No Authorization header found")
|
||||||
|
|
||||||
user_or_api_key = await cls._get_auth_type(auth_header)
|
user_or_api_key = await cls._get_auth_type(request, auth_header)
|
||||||
if user_or_api_key is None:
|
if user_or_api_key is None:
|
||||||
raise Exception("Invalid Authorization header")
|
raise Exception("Invalid Authorization header")
|
||||||
return user_or_api_key
|
return user_or_api_key
|
||||||
@ -62,14 +68,22 @@ class Route(RouteUserExtension):
|
|||||||
async def get_authenticated_user_or_api_key_or_default(
|
async def get_authenticated_user_or_api_key_or_default(
|
||||||
cls,
|
cls,
|
||||||
) -> Optional[Union[User, ApiKey]]:
|
) -> Optional[Union[User, ApiKey]]:
|
||||||
|
request = get_request()
|
||||||
|
if request is None:
|
||||||
|
return None
|
||||||
|
|
||||||
auth_header = request.headers.get("Authorization", None)
|
auth_header = request.headers.get("Authorization", None)
|
||||||
if not auth_header:
|
if not auth_header:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return await cls._get_auth_type(auth_header)
|
return await cls._get_auth_type(request, auth_header)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def is_authorized(cls) -> bool:
|
async def is_authorized(cls) -> bool:
|
||||||
|
request = get_request()
|
||||||
|
if request is None:
|
||||||
|
return False
|
||||||
|
|
||||||
auth_header = request.headers.get("Authorization", None)
|
auth_header = request.headers.get("Authorization", None)
|
||||||
if not auth_header:
|
if not auth_header:
|
||||||
return False
|
return False
|
||||||
@ -99,26 +113,25 @@ class Route(RouteUserExtension):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
async def decorator(*args, **kwargs):
|
async def decorator(request: Request, *args, **kwargs):
|
||||||
if skip_in_dev and Environment.get_environment() == "development":
|
if skip_in_dev and Environment.get_environment() == "development":
|
||||||
if iscoroutinefunction(f):
|
if iscoroutinefunction(f):
|
||||||
return await f(*args, **kwargs)
|
return await f(request, *args, **kwargs)
|
||||||
return f(*args, **kwargs)
|
return f(request, *args, **kwargs)
|
||||||
|
|
||||||
if not await cls.is_authorized():
|
if not await cls.is_authorized():
|
||||||
return unauthorized()
|
return unauthorized()
|
||||||
|
|
||||||
if iscoroutinefunction(f):
|
if iscoroutinefunction(f):
|
||||||
return await f(*args, **kwargs)
|
return await f(request, *args, **kwargs)
|
||||||
return f(*args, **kwargs)
|
return f(request, *args, **kwargs)
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def route(cls, path=None, **kwargs):
|
def route(cls, path=None, **kwargs):
|
||||||
def inner(fn):
|
def inner(fn):
|
||||||
cross_origin(fn)
|
cls.registered_routes.append(StarletteRoute(path, fn, **kwargs))
|
||||||
cls.registered_routes[path] = (fn, kwargs)
|
|
||||||
return fn
|
return fn
|
||||||
|
|
||||||
return inner
|
return inner
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from flask import request, Request, has_request_context
|
|
||||||
from keycloak import KeycloakAuthenticationError, KeycloakConnectionError
|
from keycloak import KeycloakAuthenticationError, KeycloakConnectionError
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
from api.auth.keycloak_client import Keycloak
|
from api.auth.keycloak_client import Keycloak
|
||||||
from api.auth.keycloak_user import KeycloakUser
|
from api.auth.keycloak_user import KeycloakUser
|
||||||
|
from api.middleware.request import get_request
|
||||||
from core.get_value import get_value
|
from core.get_value import get_value
|
||||||
from core.logger import Logger
|
from core.logger import Logger
|
||||||
from data.schemas.administration.user import User
|
from data.schemas.administration.user import User
|
||||||
@ -19,8 +20,8 @@ logger = Logger(__name__)
|
|||||||
class RouteUserExtension:
|
class RouteUserExtension:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_user_id_from_token(cls) -> Optional[str]:
|
def _get_user_id_from_token(cls, request: Request) -> Optional[str]:
|
||||||
token = cls.get_token()
|
token = cls.get_token(request)
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -34,7 +35,7 @@ class RouteUserExtension:
|
|||||||
return get_value(user_info, "sub", str)
|
return get_value(user_info, "sub", str)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_token() -> Optional[str]:
|
def get_token(request: Request) -> Optional[str]:
|
||||||
if "Authorization" not in request.headers:
|
if "Authorization" not in request.headers:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -45,23 +46,24 @@ class RouteUserExtension:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_user(cls) -> Optional[User]:
|
async def get_user(cls) -> Optional[User]:
|
||||||
if not has_request_context():
|
request = get_request()
|
||||||
|
if request is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
user_id = cls._get_user_id_from_token()
|
user_id = cls._get_user_id_from_token(request)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
user = await userDao.find_by_keycloak_id(user_id)
|
return await userDao.find_by_keycloak_id(user_id)
|
||||||
if user is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_dev_user(cls) -> Optional[User]:
|
async def get_dev_user(cls) -> Optional[User]:
|
||||||
|
request = get_request()
|
||||||
|
if request is None:
|
||||||
|
return None
|
||||||
|
|
||||||
return await userDao.find_single_by(
|
return await userDao.find_single_by(
|
||||||
[{User.keycloak_id: cls.get_token()}, {User.deleted: False}]
|
[{User.keycloak_id: cls.get_token(request)}, {User.deleted: False}]
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -71,56 +73,6 @@ class RouteUserExtension:
|
|||||||
user = await cls.get_dev_user()
|
user = await cls.get_dev_user()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _flatten_groups(cls, groups):
|
|
||||||
flat_list = []
|
|
||||||
for group in groups:
|
|
||||||
flat_list.append(group)
|
|
||||||
if "subGroups" in group and group["subGroups"]:
|
|
||||||
flat_list.extend(cls._flatten_groups(group["subGroups"]))
|
|
||||||
return flat_list
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _map_keycloak_groups_with_roles(cls, user: User):
|
|
||||||
try:
|
|
||||||
roles = {x.name: x for x in await roleDao.get_all()}
|
|
||||||
groups = cls._flatten_groups(Keycloak.admin.get_groups(full_hierarchy=True))
|
|
||||||
groups_with_role = [x["name"] for x in groups if x["name"] in roles.keys()]
|
|
||||||
|
|
||||||
user_groups_with_role = [
|
|
||||||
x["name"]
|
|
||||||
for x in Keycloak.admin.get_user_groups(user.keycloak_id)
|
|
||||||
if x["name"] in roles.keys()
|
|
||||||
]
|
|
||||||
user_roles = set(
|
|
||||||
x.name for x in await user.roles if x.name in groups_with_role
|
|
||||||
)
|
|
||||||
missing_groups = set(user_groups_with_role) - set(user_roles)
|
|
||||||
missing_roles = set(user_roles) - set(user_groups_with_role)
|
|
||||||
|
|
||||||
if len(missing_groups) > 0:
|
|
||||||
await roleUserDao.create_many(
|
|
||||||
[
|
|
||||||
RoleUser(0, (await roleDao.get_by_name(group)).id, user.id)
|
|
||||||
for group in missing_groups
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(missing_roles) > 0:
|
|
||||||
await roleUserDao.delete_many(
|
|
||||||
[
|
|
||||||
await roleUserDao.get_single_by(
|
|
||||||
[
|
|
||||||
{RoleUser.role_id: roles[role].id},
|
|
||||||
{RoleUser.user_id: user.id},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
for role in missing_roles
|
|
||||||
]
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to map user groups", e)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _create_user(cls, kc_user: KeycloakUser):
|
async def _create_user(cls, kc_user: KeycloakUser):
|
||||||
try:
|
try:
|
||||||
@ -140,8 +92,8 @@ class RouteUserExtension:
|
|||||||
logger.error("Failed to find or create user", e)
|
logger.error("Failed to find or create user", e)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def verify_login(cls, req: Request) -> bool:
|
async def verify_login(cls, request: Request) -> bool:
|
||||||
auth_header = req.headers.get("Authorization", None)
|
auth_header = request.headers.get("Authorization", None)
|
||||||
|
|
||||||
if not auth_header or not auth_header.startswith("Bearer "):
|
if not auth_header or not auth_header.startswith("Bearer "):
|
||||||
return False
|
return False
|
||||||
@ -155,11 +107,8 @@ class RouteUserExtension:
|
|||||||
|
|
||||||
user = await cls.get_user()
|
user = await cls.get_user()
|
||||||
if user is None:
|
if user is None:
|
||||||
u_id = await cls._create_user(KeycloakUser(user_info))
|
await cls._create_user(KeycloakUser(user_info))
|
||||||
await cls._map_keycloak_groups_with_roles(await userDao.get_by_id(u_id))
|
|
||||||
return True
|
return True
|
||||||
else:
|
|
||||||
await cls._map_keycloak_groups_with_roles(user)
|
|
||||||
|
|
||||||
if user.deleted:
|
if user.deleted:
|
||||||
return False
|
return False
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from flask import send_file
|
from starlette.requests import Request
|
||||||
from werkzeug.exceptions import NotFound
|
from starlette.responses import FileResponse
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
from api.route import Route
|
from api.route import Route
|
||||||
from core.logger import APILogger
|
from core.logger import APILogger
|
||||||
@ -9,19 +10,23 @@ from core.logger import APILogger
|
|||||||
logger = APILogger(__name__)
|
logger = APILogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@Route.get(f"/api/files/<path:file_path>")
|
@Route.get("/api/files/{file_path:path}")
|
||||||
def get_file(file_path: str):
|
async def get_file(request: Request):
|
||||||
|
file_path = request.path_params["file_path"]
|
||||||
name = file_path
|
name = file_path
|
||||||
if "/" in file_path:
|
if "/" in file_path:
|
||||||
name = file_path.split("/")[-1]
|
name = file_path.split("/")[-1]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return send_file(
|
return FileResponse(
|
||||||
f"../files/{file_path}", download_name=name, as_attachment=True
|
path=f"files/{file_path}",
|
||||||
|
filename=name,
|
||||||
|
media_type="application/octet-stream",
|
||||||
)
|
)
|
||||||
except NotFound:
|
except HTTPException as e:
|
||||||
return {"error": "File not found"}, 404
|
if e.status_code == 404:
|
||||||
except Exception as e:
|
return {"error": "File not found"}, 404
|
||||||
error_id = uuid4()
|
else:
|
||||||
logger.error(f"Error {error_id} getting file {file_path}", e)
|
error_id = uuid4()
|
||||||
return {"error": f"File error. ErrorId: {error_id}"}, 500
|
logger.error(f"Error {error_id} getting file {file_path}", e)
|
||||||
|
return {"error": f"File error. ErrorId: {error_id}"}, 500
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from ariadne import graphql
|
from ariadne import graphql
|
||||||
from flask import request, jsonify
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
from api.route import Route
|
from api.route import Route
|
||||||
from api_graphql.service.schema import schema
|
from api_graphql.service.schema import schema
|
||||||
@ -10,11 +11,11 @@ logger = Logger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@Route.post(f"{BasePath}")
|
@Route.post(f"{BasePath}")
|
||||||
async def graphql_endpoint():
|
async def graphql_endpoint(request: Request):
|
||||||
data = request.get_json()
|
data = await request.json()
|
||||||
|
|
||||||
# Note: Passing the request to the context is optional.
|
# Note: Passing the request to the context is optional.
|
||||||
# In Flask, the current request is always accessible as flask.request
|
# In Starlette, the current request is accessible as request
|
||||||
success, result = await graphql(schema, data, context_value=request)
|
success, result = await graphql(schema, data, context_value=request)
|
||||||
|
|
||||||
status_code = 200
|
status_code = 200
|
||||||
@ -24,4 +25,4 @@ async def graphql_endpoint():
|
|||||||
]
|
]
|
||||||
status_code = max(status_codes, default=200)
|
status_code = max(status_codes, default=200)
|
||||||
|
|
||||||
return jsonify(result), status_code
|
return JSONResponse(result, status_code=status_code)
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
from ariadne.explorer import ExplorerPlayground
|
from ariadne.explorer import ExplorerPlayground
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
from api.route import Route
|
from api.route import Route
|
||||||
from core.environment import Environment
|
from core.environment import Environment
|
||||||
@ -10,7 +12,7 @@ logger = Logger(__name__)
|
|||||||
|
|
||||||
@Route.get(f"{BasePath}/playground")
|
@Route.get(f"{BasePath}/playground")
|
||||||
@Route.authorize(skip_in_dev=True)
|
@Route.authorize(skip_in_dev=True)
|
||||||
async def playground():
|
async def playground(r: Request):
|
||||||
if Environment.get_environment() != "development":
|
if Environment.get_environment() != "development":
|
||||||
return "", 403
|
return "", 403
|
||||||
|
|
||||||
@ -19,7 +21,6 @@ async def playground():
|
|||||||
if dev_user:
|
if dev_user:
|
||||||
request_global_headers = {f"Authorization": f"DEV-User {dev_user}"}
|
request_global_headers = {f"Authorization": f"DEV-User {dev_user}"}
|
||||||
|
|
||||||
return (
|
return HTMLResponse(
|
||||||
ExplorerPlayground(request_global_headers=request_global_headers).html(None),
|
ExplorerPlayground(request_global_headers=request_global_headers).html(None)
|
||||||
200,
|
|
||||||
)
|
)
|
||||||
|
@ -1,7 +1,16 @@
|
|||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
from api.route import Route
|
from api.route import Route
|
||||||
|
from core.configuration.feature_flags import FeatureFlags
|
||||||
|
from core.configuration.feature_flags_enum import FeatureFlagsEnum
|
||||||
from version import VERSION
|
from version import VERSION
|
||||||
|
|
||||||
|
|
||||||
@Route.get(f"/api/version")
|
@Route.get(f"/api/version")
|
||||||
def version():
|
async def version(r: Request):
|
||||||
return VERSION
|
feature = await FeatureFlags.has_feature(FeatureFlagsEnum.version_endpoint)
|
||||||
|
if not feature:
|
||||||
|
return JSONResponse("DISABLED", status_code=403)
|
||||||
|
|
||||||
|
return JSONResponse(VERSION)
|
||||||
|
@ -4,6 +4,7 @@ from api_graphql.abc.filter.bool_filter import BoolFilter
|
|||||||
from api_graphql.abc.filter.int_filter import IntFilter
|
from api_graphql.abc.filter.int_filter import IntFilter
|
||||||
from api_graphql.abc.filter.string_filter import StringFilter
|
from api_graphql.abc.filter.string_filter import StringFilter
|
||||||
from api_graphql.abc.filter_abc import FilterABC
|
from api_graphql.abc.filter_abc import FilterABC
|
||||||
|
from api_graphql.filter.fuzzy_filter import FuzzyFilter
|
||||||
|
|
||||||
|
|
||||||
class DbModelFilterABC[T](FilterABC[T]):
|
class DbModelFilterABC[T](FilterABC[T]):
|
||||||
@ -18,3 +19,5 @@ class DbModelFilterABC[T](FilterABC[T]):
|
|||||||
self.add_field("editor", IntFilter)
|
self.add_field("editor", IntFilter)
|
||||||
self.add_field("createdUtc", StringFilter, "created")
|
self.add_field("createdUtc", StringFilter, "created")
|
||||||
self.add_field("updatedUtc", StringFilter, "updated")
|
self.add_field("updatedUtc", StringFilter, "updated")
|
||||||
|
|
||||||
|
self.add_field("fuzzy", FuzzyFilter)
|
||||||
|
@ -16,7 +16,7 @@ class MutationABC(QueryABC):
|
|||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
mutation_name: str,
|
mutation_name: str,
|
||||||
require_any_permission: list[Permissions] = None,
|
require_any_permission=None,
|
||||||
public: bool = False,
|
public: bool = False,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@ -27,6 +27,8 @@ class MutationABC(QueryABC):
|
|||||||
:param bool public: Define if the field can resolve without authentication
|
:param bool public: Define if the field can resolve without authentication
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
if require_any_permission is None:
|
||||||
|
require_any_permission = []
|
||||||
from api_graphql.definition import QUERIES
|
from api_graphql.definition import QUERIES
|
||||||
|
|
||||||
self.field(
|
self.field(
|
||||||
|
@ -4,12 +4,13 @@ from enum import Enum
|
|||||||
from types import NoneType
|
from types import NoneType
|
||||||
from typing import Callable, Type, get_args, Any, Union
|
from typing import Callable, Type, get_args, Any, Union
|
||||||
|
|
||||||
from ariadne import ObjectType
|
from ariadne import ObjectType, SubscriptionType
|
||||||
from graphql import GraphQLResolveInfo
|
from graphql import GraphQLResolveInfo
|
||||||
from typing_extensions import deprecated
|
from typing_extensions import deprecated
|
||||||
|
|
||||||
from api.route import Route
|
from api.route import Route
|
||||||
from api_graphql.abc.collection_filter_abc import CollectionFilterABC
|
from api_graphql.abc.collection_filter_abc import CollectionFilterABC
|
||||||
|
from api_graphql.abc.field_abc import FieldABC
|
||||||
from api_graphql.abc.input_abc import InputABC
|
from api_graphql.abc.input_abc import InputABC
|
||||||
from api_graphql.abc.sort_abc import Sort
|
from api_graphql.abc.sort_abc import Sort
|
||||||
from api_graphql.field.collection_field import CollectionField
|
from api_graphql.field.collection_field import CollectionField
|
||||||
@ -20,6 +21,7 @@ from api_graphql.field.mutation_field import MutationField
|
|||||||
from api_graphql.field.mutation_field_builder import MutationFieldBuilder
|
from api_graphql.field.mutation_field_builder import MutationFieldBuilder
|
||||||
from api_graphql.field.resolver_field import ResolverField
|
from api_graphql.field.resolver_field import ResolverField
|
||||||
from api_graphql.field.resolver_field_builder import ResolverFieldBuilder
|
from api_graphql.field.resolver_field_builder import ResolverFieldBuilder
|
||||||
|
from api_graphql.field.subscription_field import SubscriptionField
|
||||||
from api_graphql.service.collection_result import CollectionResult
|
from api_graphql.service.collection_result import CollectionResult
|
||||||
from api_graphql.service.exceptions import (
|
from api_graphql.service.exceptions import (
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
@ -29,6 +31,7 @@ from api_graphql.service.exceptions import (
|
|||||||
from api_graphql.service.query_context import QueryContext
|
from api_graphql.service.query_context import QueryContext
|
||||||
from api_graphql.typing import TRequireAnyPermissions, TRequireAnyResolvers
|
from api_graphql.typing import TRequireAnyPermissions, TRequireAnyResolvers
|
||||||
from core.logger import APILogger
|
from core.logger import APILogger
|
||||||
|
from core.string import first_to_lower
|
||||||
from service.permission.permissions_enum import Permissions
|
from service.permission.permissions_enum import Permissions
|
||||||
|
|
||||||
logger = APILogger(__name__)
|
logger = APILogger(__name__)
|
||||||
@ -40,6 +43,7 @@ class QueryABC(ObjectType):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def __init__(self, name: str = __name__):
|
def __init__(self, name: str = __name__):
|
||||||
ObjectType.__init__(self, name)
|
ObjectType.__init__(self, name)
|
||||||
|
self._subscriptions: dict[str, SubscriptionType] = {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _authorize():
|
async def _authorize():
|
||||||
@ -67,12 +71,12 @@ class QueryABC(ObjectType):
|
|||||||
*args,
|
*args,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
|
info = args[0]
|
||||||
|
|
||||||
if len(permissions) > 0:
|
if len(permissions) > 0:
|
||||||
user = await Route.get_authenticated_user_or_api_key_or_default()
|
user = await Route.get_authenticated_user_or_api_key_or_default()
|
||||||
perms = await user.permissions
|
|
||||||
has_perms = [await user.has_permission(x) for x in permissions]
|
|
||||||
if user is not None and all(
|
if user is not None and all(
|
||||||
has_perms
|
[await user.has_permission(x) for x in permissions]
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -134,7 +138,12 @@ class QueryABC(ObjectType):
|
|||||||
skip = kwargs["skip"]
|
skip = kwargs["skip"]
|
||||||
|
|
||||||
collection = await field.dao.find_by(filters, sorts, take, skip)
|
collection = await field.dao.find_by(filters, sorts, take, skip)
|
||||||
res = CollectionResult(await field.dao.count(), len(collection), collection)
|
if field.direct_result:
|
||||||
|
return collection
|
||||||
|
|
||||||
|
res = CollectionResult(
|
||||||
|
await field.dao.count(filters), len(collection), collection
|
||||||
|
)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def collection_wrapper(*args, **kwargs):
|
async def collection_wrapper(*args, **kwargs):
|
||||||
@ -171,11 +180,12 @@ class QueryABC(ObjectType):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def resolver_wrapper(*args, **kwargs):
|
async def resolver_wrapper(*args, **kwargs):
|
||||||
return (
|
result = (
|
||||||
await field.resolver(*args, **kwargs)
|
await field.resolver(*args, **kwargs)
|
||||||
if iscoroutinefunction(field.resolver)
|
if iscoroutinefunction(field.resolver)
|
||||||
else field.resolver(*args, **kwargs)
|
else field.resolver(*args, **kwargs)
|
||||||
)
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
if isinstance(field, DaoField):
|
if isinstance(field, DaoField):
|
||||||
resolver = dao_wrapper
|
resolver = dao_wrapper
|
||||||
@ -205,6 +215,13 @@ class QueryABC(ObjectType):
|
|||||||
|
|
||||||
resolver = input_wrapper
|
resolver = input_wrapper
|
||||||
|
|
||||||
|
elif isinstance(field, SubscriptionField):
|
||||||
|
|
||||||
|
async def sub_wrapper(sub: QueryABC, info: GraphQLResolveInfo, **kwargs):
|
||||||
|
return await resolver_wrapper(sub, info, **kwargs)
|
||||||
|
|
||||||
|
resolver = sub_wrapper
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown field type: {field.name}")
|
raise ValueError(f"Unknown field type: {field.name}")
|
||||||
|
|
||||||
@ -222,7 +239,12 @@ class QueryABC(ObjectType):
|
|||||||
result = await resolver(*args, **kwargs)
|
result = await resolver(*args, **kwargs)
|
||||||
|
|
||||||
if field.require_any is not None:
|
if field.require_any is not None:
|
||||||
await self._require_any(result, *field.require_any, *args, **kwargs)
|
await self._require_any(
|
||||||
|
result,
|
||||||
|
*field.require_any,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@ -252,6 +274,9 @@ class QueryABC(ObjectType):
|
|||||||
self.field(
|
self.field(
|
||||||
MutationFieldBuilder(name)
|
MutationFieldBuilder(name)
|
||||||
.with_resolver(f)
|
.with_resolver(f)
|
||||||
|
.with_change_broadcast(
|
||||||
|
f"{first_to_lower(self.name.replace("Mutation", ""))}Change"
|
||||||
|
)
|
||||||
.with_input(input_type, input_key)
|
.with_input(input_type, input_key)
|
||||||
.with_require_any_permission(require_any_permission)
|
.with_require_any_permission(require_any_permission)
|
||||||
.with_public(public)
|
.with_public(public)
|
||||||
@ -273,6 +298,8 @@ class QueryABC(ObjectType):
|
|||||||
for f in filters:
|
for f in filters:
|
||||||
collection = list(filter(lambda x: f.filter(x), collection))
|
collection = list(filter(lambda x: f.filter(x), collection))
|
||||||
|
|
||||||
|
total_count = len(collection)
|
||||||
|
|
||||||
if sort is not None:
|
if sort is not None:
|
||||||
|
|
||||||
def f_sort(x: object, k: str):
|
def f_sort(x: object, k: str):
|
||||||
|
51
api/src/api_graphql/abc/subscription_abc.py
Normal file
51
api/src/api_graphql/abc/subscription_abc.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
from abc import abstractmethod
|
||||||
|
from asyncio import iscoroutinefunction
|
||||||
|
|
||||||
|
from ariadne import SubscriptionType
|
||||||
|
|
||||||
|
from api.middleware.request import get_request
|
||||||
|
from api_graphql.abc.query_abc import QueryABC
|
||||||
|
from api_graphql.field.subscription_field_builder import SubscriptionFieldBuilder
|
||||||
|
from core.logger import APILogger
|
||||||
|
|
||||||
|
logger = APILogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionABC(SubscriptionType, QueryABC):
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def __init__(self):
|
||||||
|
SubscriptionType.__init__(self)
|
||||||
|
|
||||||
|
def subscribe(self, builder: SubscriptionFieldBuilder):
|
||||||
|
field = builder.build()
|
||||||
|
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
if not field.public:
|
||||||
|
await self._authorize()
|
||||||
|
|
||||||
|
if (
|
||||||
|
field.require_any is None
|
||||||
|
and not field.public
|
||||||
|
and field.require_any_permission
|
||||||
|
):
|
||||||
|
await self._require_any_permission(field.require_any_permission)
|
||||||
|
|
||||||
|
result = (
|
||||||
|
await field.resolver(*args, **kwargs)
|
||||||
|
if iscoroutinefunction(field.resolver)
|
||||||
|
else field.resolver(*args, **kwargs)
|
||||||
|
)
|
||||||
|
|
||||||
|
if field.require_any is not None:
|
||||||
|
await self._require_any(
|
||||||
|
result,
|
||||||
|
*field.require_any,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
self.set_field(field.name, wrapper)
|
||||||
|
self.set_source(field.name, field.generator)
|
@ -4,6 +4,7 @@ import os
|
|||||||
from api_graphql.abc.db_model_query_abc import DbModelQueryABC
|
from api_graphql.abc.db_model_query_abc import DbModelQueryABC
|
||||||
from api_graphql.abc.mutation_abc import MutationABC
|
from api_graphql.abc.mutation_abc import MutationABC
|
||||||
from api_graphql.abc.query_abc import QueryABC
|
from api_graphql.abc.query_abc import QueryABC
|
||||||
|
from api_graphql.abc.subscription_abc import SubscriptionABC
|
||||||
from api_graphql.query import Query
|
from api_graphql.query import Query
|
||||||
|
|
||||||
|
|
||||||
@ -19,7 +20,7 @@ def import_graphql_schema_part(part: str):
|
|||||||
import_graphql_schema_part("queries")
|
import_graphql_schema_part("queries")
|
||||||
import_graphql_schema_part("mutations")
|
import_graphql_schema_part("mutations")
|
||||||
|
|
||||||
sub_query_classes = [DbModelQueryABC, MutationABC]
|
sub_query_classes = [DbModelQueryABC, MutationABC, SubscriptionABC]
|
||||||
query_classes = [
|
query_classes = [
|
||||||
*[y for x in sub_query_classes for y in x.__subclasses__()],
|
*[y for x in sub_query_classes for y in x.__subclasses__()],
|
||||||
*[x for x in QueryABC.__subclasses__() if x not in sub_query_classes],
|
*[x for x in QueryABC.__subclasses__() if x not in sub_query_classes],
|
||||||
|
@ -20,6 +20,7 @@ class DaoField(FieldABC):
|
|||||||
dao: DataAccessObjectABC = None,
|
dao: DataAccessObjectABC = None,
|
||||||
filter_type: Type[FilterABC] = None,
|
filter_type: Type[FilterABC] = None,
|
||||||
sort_type: Type[T] = None,
|
sort_type: Type[T] = None,
|
||||||
|
direct_result: bool = False,
|
||||||
):
|
):
|
||||||
FieldABC.__init__(self, name, require_any_permission, require_any, public)
|
FieldABC.__init__(self, name, require_any_permission, require_any, public)
|
||||||
self._name = name
|
self._name = name
|
||||||
@ -28,6 +29,7 @@ class DaoField(FieldABC):
|
|||||||
self._dao = dao
|
self._dao = dao
|
||||||
self._filter_type = filter_type
|
self._filter_type = filter_type
|
||||||
self._sort_type = sort_type
|
self._sort_type = sort_type
|
||||||
|
self._direct_result = direct_result
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dao(self) -> Optional[DataAccessObjectABC]:
|
def dao(self) -> Optional[DataAccessObjectABC]:
|
||||||
@ -42,3 +44,7 @@ class DaoField(FieldABC):
|
|||||||
@property
|
@property
|
||||||
def sort_type(self) -> Optional[Type[T]]:
|
def sort_type(self) -> Optional[Type[T]]:
|
||||||
return self._sort_type
|
return self._sort_type
|
||||||
|
|
||||||
|
@property
|
||||||
|
def direct_result(self) -> bool:
|
||||||
|
return self._direct_result
|
||||||
|
@ -15,6 +15,7 @@ class DaoFieldBuilder(FieldBuilderABC):
|
|||||||
self._dao = None
|
self._dao = None
|
||||||
self._filter_type = None
|
self._filter_type = None
|
||||||
self._sort_type = None
|
self._sort_type = None
|
||||||
|
self._direct_result = False
|
||||||
|
|
||||||
def with_dao(self, dao: DataAccessObjectABC) -> Self:
|
def with_dao(self, dao: DataAccessObjectABC) -> Self:
|
||||||
assert dao is not None, "dao cannot be None"
|
assert dao is not None, "dao cannot be None"
|
||||||
@ -31,6 +32,10 @@ class DaoFieldBuilder(FieldBuilderABC):
|
|||||||
self._sort_type = sort_type
|
self._sort_type = sort_type
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
def with_direct_result(self) -> Self:
|
||||||
|
self._direct_result = True
|
||||||
|
return self
|
||||||
|
|
||||||
def build(self) -> DaoField:
|
def build(self) -> DaoField:
|
||||||
assert self._dao is not None, "dao cannot be None"
|
assert self._dao is not None, "dao cannot be None"
|
||||||
return DaoField(
|
return DaoField(
|
||||||
@ -41,4 +46,5 @@ class DaoFieldBuilder(FieldBuilderABC):
|
|||||||
self._dao,
|
self._dao,
|
||||||
self._filter_type,
|
self._filter_type,
|
||||||
self._sort_type,
|
self._sort_type,
|
||||||
|
self._direct_result,
|
||||||
)
|
)
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
|
from asyncio import iscoroutinefunction
|
||||||
from typing import Self, Type
|
from typing import Self, Type
|
||||||
|
|
||||||
from ariadne.types import Resolver
|
from ariadne.types import Resolver
|
||||||
|
|
||||||
|
from api.broadcast import broadcast
|
||||||
from api_graphql.abc.field_builder_abc import FieldBuilderABC
|
from api_graphql.abc.field_builder_abc import FieldBuilderABC
|
||||||
from api_graphql.abc.input_abc import InputABC
|
from api_graphql.abc.input_abc import InputABC
|
||||||
from api_graphql.field.mutation_field import MutationField
|
from api_graphql.field.mutation_field import MutationField
|
||||||
@ -18,9 +20,41 @@ class MutationFieldBuilder(FieldBuilderABC):
|
|||||||
|
|
||||||
def with_resolver(self, resolver: Resolver) -> Self:
|
def with_resolver(self, resolver: Resolver) -> Self:
|
||||||
assert resolver is not None, "resolver cannot be None"
|
assert resolver is not None, "resolver cannot be None"
|
||||||
|
|
||||||
self._resolver = resolver
|
self._resolver = resolver
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
def with_broadcast(self, source: str):
|
||||||
|
assert self._resolver is not None, "resolver cannot be None for broadcast"
|
||||||
|
|
||||||
|
resolver = self._resolver
|
||||||
|
|
||||||
|
async def resolver_wrapper(*args, **kwargs):
|
||||||
|
result = (
|
||||||
|
await resolver(*args, **kwargs)
|
||||||
|
if iscoroutinefunction(resolver)
|
||||||
|
else resolver(*args, **kwargs)
|
||||||
|
)
|
||||||
|
await broadcast.publish(f"{source}", result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def with_change_broadcast(self, source: str):
|
||||||
|
assert self._resolver is not None, "resolver cannot be None for broadcast"
|
||||||
|
|
||||||
|
resolver = self._resolver
|
||||||
|
|
||||||
|
async def resolver_wrapper(*args, **kwargs):
|
||||||
|
result = (
|
||||||
|
await resolver(*args, **kwargs)
|
||||||
|
if iscoroutinefunction(resolver)
|
||||||
|
else resolver(*args, **kwargs)
|
||||||
|
)
|
||||||
|
await broadcast.publish(f"{source}", {})
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._resolver = resolver_wrapper
|
||||||
|
return self
|
||||||
|
|
||||||
def with_input(self, input_type: Type[InputABC], input_key: str = None) -> Self:
|
def with_input(self, input_type: Type[InputABC], input_key: str = None) -> Self:
|
||||||
self._input_type = input_type
|
self._input_type = input_type
|
||||||
self._input_key = input_key
|
self._input_key = input_key
|
||||||
|
@ -16,11 +16,17 @@ class ResolverField(FieldABC):
|
|||||||
require_any: TRequireAny = None,
|
require_any: TRequireAny = None,
|
||||||
public: bool = False,
|
public: bool = False,
|
||||||
resolver: Resolver = None,
|
resolver: Resolver = None,
|
||||||
|
direct_result: bool = False,
|
||||||
):
|
):
|
||||||
FieldABC.__init__(self, name, require_any_permission, require_any, public)
|
FieldABC.__init__(self, name, require_any_permission, require_any, public)
|
||||||
|
|
||||||
self._resolver = resolver
|
self._resolver = resolver
|
||||||
|
self._direct_result = direct_result
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def resolver(self) -> Optional[Resolver]:
|
def resolver(self) -> Optional[Resolver]:
|
||||||
return self._resolver
|
return self._resolver
|
||||||
|
|
||||||
|
@property
|
||||||
|
def direct_result(self) -> bool:
|
||||||
|
return self._direct_result
|
||||||
|
@ -12,12 +12,17 @@ class ResolverFieldBuilder(FieldBuilderABC):
|
|||||||
FieldBuilderABC.__init__(self, name)
|
FieldBuilderABC.__init__(self, name)
|
||||||
|
|
||||||
self._resolver = None
|
self._resolver = None
|
||||||
|
self._direct_result = False
|
||||||
|
|
||||||
def with_resolver(self, resolver: Resolver) -> Self:
|
def with_resolver(self, resolver: Resolver) -> Self:
|
||||||
assert resolver is not None, "resolver cannot be None"
|
assert resolver is not None, "resolver cannot be None"
|
||||||
self._resolver = resolver
|
self._resolver = resolver
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
def with_direct_result(self) -> Self:
|
||||||
|
self._direct_result = True
|
||||||
|
return self
|
||||||
|
|
||||||
def build(self) -> ResolverField:
|
def build(self) -> ResolverField:
|
||||||
assert self._resolver is not None, "resolver cannot be None"
|
assert self._resolver is not None, "resolver cannot be None"
|
||||||
return ResolverField(
|
return ResolverField(
|
||||||
@ -26,4 +31,5 @@ class ResolverFieldBuilder(FieldBuilderABC):
|
|||||||
self._require_any,
|
self._require_any,
|
||||||
self._public,
|
self._public,
|
||||||
self._resolver,
|
self._resolver,
|
||||||
|
self._direct_result,
|
||||||
)
|
)
|
||||||
|
32
api/src/api_graphql/field/subscription_field.py
Normal file
32
api/src/api_graphql/field/subscription_field.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ariadne.types import Resolver
|
||||||
|
|
||||||
|
from api_graphql.abc.field_abc import FieldABC
|
||||||
|
from api_graphql.typing import TRequireAny
|
||||||
|
from service.permission.permissions_enum import Permissions
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionField(FieldABC):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
require_any_permission: list[Permissions] = None,
|
||||||
|
require_any: TRequireAny = None,
|
||||||
|
public: bool = False,
|
||||||
|
resolver: Resolver = None,
|
||||||
|
generator: Resolver = None,
|
||||||
|
):
|
||||||
|
FieldABC.__init__(self, name, require_any_permission, require_any, public)
|
||||||
|
|
||||||
|
self._resolver = resolver
|
||||||
|
self._generator = generator
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resolver(self) -> Optional[Resolver]:
|
||||||
|
return self._resolver
|
||||||
|
|
||||||
|
@property
|
||||||
|
def generator(self) -> Optional[Resolver]:
|
||||||
|
return self._generator
|
46
api/src/api_graphql/field/subscription_field_builder.py
Normal file
46
api/src/api_graphql/field/subscription_field_builder.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
from typing import Self, AsyncGenerator
|
||||||
|
|
||||||
|
from ariadne.types import Resolver
|
||||||
|
|
||||||
|
from api.broadcast import broadcast
|
||||||
|
from api_graphql.abc.field_builder_abc import FieldBuilderABC
|
||||||
|
from api_graphql.field.subscription_field import SubscriptionField
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionFieldBuilder(FieldBuilderABC):
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
FieldBuilderABC.__init__(self, name)
|
||||||
|
|
||||||
|
self._resolver = None
|
||||||
|
self._generator = None
|
||||||
|
|
||||||
|
def with_resolver(self, resolver: Resolver) -> Self:
|
||||||
|
assert resolver is not None, "resolver cannot be None"
|
||||||
|
self._resolver = resolver
|
||||||
|
return self
|
||||||
|
|
||||||
|
def with_generator(self, generator: Resolver) -> Self:
|
||||||
|
assert generator is not None, "generator cannot be None"
|
||||||
|
self._generator = generator
|
||||||
|
return self
|
||||||
|
|
||||||
|
def build(self) -> SubscriptionField:
|
||||||
|
assert self._resolver is not None, "resolver cannot be None"
|
||||||
|
if self._generator is None:
|
||||||
|
|
||||||
|
async def generator(*args, **kwargs) -> AsyncGenerator[str, None]:
|
||||||
|
async with broadcast.subscribe(channel=self._name) as subscriber:
|
||||||
|
async for message in subscriber:
|
||||||
|
yield message
|
||||||
|
|
||||||
|
self._generator = generator
|
||||||
|
|
||||||
|
return SubscriptionField(
|
||||||
|
self._name,
|
||||||
|
self._require_any_permission,
|
||||||
|
self._require_any,
|
||||||
|
self._public,
|
||||||
|
self._resolver,
|
||||||
|
self._generator,
|
||||||
|
)
|
15
api/src/api_graphql/filter/fuzzy_filter.py
Normal file
15
api/src/api_graphql/filter/fuzzy_filter.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from api_graphql.abc.filter_abc import FilterABC
|
||||||
|
|
||||||
|
|
||||||
|
class FuzzyFilter(FilterABC):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
obj: Optional[dict],
|
||||||
|
):
|
||||||
|
FilterABC.__init__(self, obj)
|
||||||
|
|
||||||
|
self.add_field("fields", list)
|
||||||
|
self.add_field("term", str)
|
||||||
|
self.add_field("threshold", int)
|
@ -9,6 +9,6 @@ class ShortUrlFilter(DbModelFilterABC):
|
|||||||
):
|
):
|
||||||
DbModelFilterABC.__init__(self, obj)
|
DbModelFilterABC.__init__(self, obj)
|
||||||
|
|
||||||
self.add_field("short_url", StringFilter)
|
self.add_field("shortUrl", StringFilter, db_name="short_url")
|
||||||
self.add_field("target_url", StringFilter)
|
self.add_field("targetUrl", StringFilter, db_name="target_url")
|
||||||
self.add_field("description", StringFilter)
|
self.add_field("description", StringFilter)
|
||||||
|
@ -21,11 +21,21 @@ input ApiKeySort {
|
|||||||
identifier: SortOrder
|
identifier: SortOrder
|
||||||
|
|
||||||
deleted: SortOrder
|
deleted: SortOrder
|
||||||
editorId: SortOrder
|
editor: UserSort
|
||||||
createdUtc: SortOrder
|
createdUtc: SortOrder
|
||||||
updatedUtc: SortOrder
|
updatedUtc: SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ApiKeyFuzzyFields {
|
||||||
|
identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
input ApiKeyFuzzy {
|
||||||
|
fields: [ApiKeyFuzzyFields]
|
||||||
|
term: String
|
||||||
|
threshold: Int
|
||||||
|
}
|
||||||
|
|
||||||
input ApiKeyFilter {
|
input ApiKeyFilter {
|
||||||
id: IntFilter
|
id: IntFilter
|
||||||
identifier: StringFilter
|
identifier: StringFilter
|
||||||
|
@ -26,10 +26,22 @@ input DomainSort {
|
|||||||
updatedUtc: SortOrder
|
updatedUtc: SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum DomainFuzzyFields {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
|
||||||
|
input DomainFuzzy {
|
||||||
|
fields: [DomainFuzzyFields]
|
||||||
|
term: String
|
||||||
|
threshold: Int
|
||||||
|
}
|
||||||
|
|
||||||
input DomainFilter {
|
input DomainFilter {
|
||||||
id: IntFilter
|
id: IntFilter
|
||||||
name: StringFilter
|
name: StringFilter
|
||||||
|
|
||||||
|
fuzzy: DomainFuzzy
|
||||||
|
|
||||||
deleted: BooleanFilter
|
deleted: BooleanFilter
|
||||||
editor: IntFilter
|
editor: IntFilter
|
||||||
createdUtc: DateFilter
|
createdUtc: DateFilter
|
||||||
|
19
api/src/api_graphql/graphql/feature_flag.gql
Normal file
19
api/src/api_graphql/graphql/feature_flag.gql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
type FeatureFlag implements DbModel {
|
||||||
|
id: ID
|
||||||
|
key: String
|
||||||
|
value: Boolean
|
||||||
|
|
||||||
|
deleted: Boolean
|
||||||
|
editor: User
|
||||||
|
createdUtc: String
|
||||||
|
updatedUtc: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeatureFlagMutation {
|
||||||
|
change(input: FeatureFlagInput!): FeatureFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
input FeatureFlagInput {
|
||||||
|
key: String!
|
||||||
|
value: Boolean!
|
||||||
|
}
|
@ -27,10 +27,22 @@ input GroupSort {
|
|||||||
updatedUtc: SortOrder
|
updatedUtc: SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum GroupFuzzyFields {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
|
||||||
|
input GroupFuzzy {
|
||||||
|
fields: [GroupFuzzyFields]
|
||||||
|
term: String
|
||||||
|
threshold: Int
|
||||||
|
}
|
||||||
|
|
||||||
input GroupFilter {
|
input GroupFilter {
|
||||||
id: IntFilter
|
id: IntFilter
|
||||||
name: StringFilter
|
name: StringFilter
|
||||||
|
|
||||||
|
fuzzy: GroupFuzzy
|
||||||
|
|
||||||
deleted: BooleanFilter
|
deleted: BooleanFilter
|
||||||
editor: IntFilter
|
editor: IntFilter
|
||||||
createdUtc: DateFilter
|
createdUtc: DateFilter
|
||||||
|
@ -7,4 +7,8 @@ type Mutation {
|
|||||||
group: GroupMutation
|
group: GroupMutation
|
||||||
domain: DomainMutation
|
domain: DomainMutation
|
||||||
shortUrl: ShortUrlMutation
|
shortUrl: ShortUrlMutation
|
||||||
|
|
||||||
|
setting: SettingMutation
|
||||||
|
userSetting: UserSettingMutation
|
||||||
|
featureFlag: FeatureFlagMutation
|
||||||
}
|
}
|
@ -14,4 +14,8 @@ type Query {
|
|||||||
domains(filter: [DomainFilter], sort: [DomainSort], skip: Int, take: Int): DomainResult
|
domains(filter: [DomainFilter], sort: [DomainSort], skip: Int, take: Int): DomainResult
|
||||||
groups(filter: [GroupFilter], sort: [GroupSort], skip: Int, take: Int): GroupResult
|
groups(filter: [GroupFilter], sort: [GroupSort], skip: Int, take: Int): GroupResult
|
||||||
shortUrls(filter: [ShortUrlFilter], sort: [ShortUrlSort], skip: Int, take: Int): ShortUrlResult
|
shortUrls(filter: [ShortUrlFilter], sort: [ShortUrlSort], skip: Int, take: Int): ShortUrlResult
|
||||||
|
|
||||||
|
settings(key: String): [Setting]
|
||||||
|
userSettings(key: String): [Setting]
|
||||||
|
featureFlags(key: String): [FeatureFlag]
|
||||||
}
|
}
|
@ -23,18 +23,31 @@ input RoleSort {
|
|||||||
description: SortOrder
|
description: SortOrder
|
||||||
|
|
||||||
deleted: SortOrder
|
deleted: SortOrder
|
||||||
editorId: SortOrder
|
editor: UserSort
|
||||||
createdUtc: SortOrder
|
createdUtc: SortOrder
|
||||||
updatedUtc: SortOrder
|
updatedUtc: SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RoleFuzzyFields {
|
||||||
|
name
|
||||||
|
description
|
||||||
|
}
|
||||||
|
|
||||||
|
input RoleFuzzy {
|
||||||
|
fields: [RoleFuzzyFields]
|
||||||
|
term: String
|
||||||
|
threshold: Int
|
||||||
|
}
|
||||||
|
|
||||||
input RoleFilter {
|
input RoleFilter {
|
||||||
id: IntFilter
|
id: IntFilter
|
||||||
name: StringFilter
|
name: StringFilter
|
||||||
description: StringFilter
|
description: StringFilter
|
||||||
|
|
||||||
|
fuzzy: RoleFuzzy
|
||||||
|
|
||||||
deleted: BooleanFilter
|
deleted: BooleanFilter
|
||||||
editorId: IntFilter
|
editor_id: IntFilter
|
||||||
createdUtc: DateFilter
|
createdUtc: DateFilter
|
||||||
updatedUtc: DateFilter
|
updatedUtc: DateFilter
|
||||||
}
|
}
|
||||||
|
19
api/src/api_graphql/graphql/setting.gql
Normal file
19
api/src/api_graphql/graphql/setting.gql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
type Setting implements DbModel {
|
||||||
|
id: ID
|
||||||
|
key: String
|
||||||
|
value: String
|
||||||
|
|
||||||
|
deleted: Boolean
|
||||||
|
editor: User
|
||||||
|
createdUtc: String
|
||||||
|
updatedUtc: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingMutation {
|
||||||
|
change(input: SettingInput!): Setting
|
||||||
|
}
|
||||||
|
|
||||||
|
input SettingInput {
|
||||||
|
key: String!
|
||||||
|
value: String!
|
||||||
|
}
|
@ -32,12 +32,27 @@ input ShortUrlSort {
|
|||||||
updatedUtc: SortOrder
|
updatedUtc: SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ShortUrlFuzzyFields {
|
||||||
|
shortUrl
|
||||||
|
targetUrl
|
||||||
|
description
|
||||||
|
}
|
||||||
|
|
||||||
|
input ShortUrlFuzzy {
|
||||||
|
fields: [ShortUrlFuzzyFields]
|
||||||
|
term: String
|
||||||
|
threshold: Int
|
||||||
|
}
|
||||||
|
|
||||||
input ShortUrlFilter {
|
input ShortUrlFilter {
|
||||||
id: IntFilter
|
id: IntFilter
|
||||||
name: StringFilter
|
shortUrl: StringFilter
|
||||||
|
targetUrl: StringFilter
|
||||||
description: StringFilter
|
description: StringFilter
|
||||||
loadingScreen: BooleanFilter
|
loadingScreen: BooleanFilter
|
||||||
|
|
||||||
|
fuzzy: ShortUrlFuzzy
|
||||||
|
|
||||||
deleted: BooleanFilter
|
deleted: BooleanFilter
|
||||||
editor: IntFilter
|
editor: IntFilter
|
||||||
createdUtc: DateFilter
|
createdUtc: DateFilter
|
||||||
@ -49,6 +64,7 @@ type ShortUrlMutation {
|
|||||||
update(input: ShortUrlUpdateInput!): ShortUrl
|
update(input: ShortUrlUpdateInput!): ShortUrl
|
||||||
delete(id: ID!): Boolean
|
delete(id: ID!): Boolean
|
||||||
restore(id: ID!): Boolean
|
restore(id: ID!): Boolean
|
||||||
|
trackVisit(id: ID!, agent: String): Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
input ShortUrlCreateInput {
|
input ShortUrlCreateInput {
|
||||||
|
16
api/src/api_graphql/graphql/subscription.gql
Normal file
16
api/src/api_graphql/graphql/subscription.gql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
scalar SubscriptionChange
|
||||||
|
|
||||||
|
type Subscription {
|
||||||
|
ping: String
|
||||||
|
|
||||||
|
apiKeyChange: SubscriptionChange
|
||||||
|
featureFlagChange: SubscriptionChange
|
||||||
|
roleChange: SubscriptionChange
|
||||||
|
settingChange: SubscriptionChange
|
||||||
|
userChange: SubscriptionChange
|
||||||
|
userSettingChange: SubscriptionChange
|
||||||
|
|
||||||
|
domainChange: SubscriptionChange
|
||||||
|
groupChange: SubscriptionChange
|
||||||
|
shortUrlChange: SubscriptionChange
|
||||||
|
}
|
@ -35,19 +35,33 @@ input UserSort {
|
|||||||
email: SortOrder
|
email: SortOrder
|
||||||
|
|
||||||
deleted: SortOrder
|
deleted: SortOrder
|
||||||
editorId: SortOrder
|
editor: UserSort
|
||||||
createdUtc: SortOrder
|
createdUtc: SortOrder
|
||||||
updatedUtc: SortOrder
|
updatedUtc: SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum UserFuzzyFields {
|
||||||
|
keycloakId
|
||||||
|
username
|
||||||
|
email
|
||||||
|
}
|
||||||
|
|
||||||
|
input UserFuzzy {
|
||||||
|
fields: [UserFuzzyFields]
|
||||||
|
term: String
|
||||||
|
threshold: Int
|
||||||
|
}
|
||||||
|
|
||||||
input UserFilter {
|
input UserFilter {
|
||||||
id: IntFilter
|
id: IntFilter
|
||||||
keycloakId: StringFilter
|
keycloakId: StringFilter
|
||||||
username: StringFilter
|
username: StringFilter
|
||||||
email: StringFilter
|
email: StringFilter
|
||||||
|
|
||||||
|
fuzzy: UserFuzzy
|
||||||
|
|
||||||
deleted: BooleanFilter
|
deleted: BooleanFilter
|
||||||
editor: IntFilter
|
editor: UserFilter
|
||||||
createdUtc: DateFilter
|
createdUtc: DateFilter
|
||||||
updatedUtc: DateFilter
|
updatedUtc: DateFilter
|
||||||
}
|
}
|
||||||
|
19
api/src/api_graphql/graphql/user_setting.gql
Normal file
19
api/src/api_graphql/graphql/user_setting.gql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
type UserSetting implements DbModel {
|
||||||
|
id: ID
|
||||||
|
key: String
|
||||||
|
value: String
|
||||||
|
|
||||||
|
deleted: Boolean
|
||||||
|
editor: User
|
||||||
|
createdUtc: String
|
||||||
|
updatedUtc: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserSettingMutation {
|
||||||
|
change(input: UserSettingInput!): UserSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
input UserSettingInput {
|
||||||
|
key: String!
|
||||||
|
value: String!
|
||||||
|
}
|
18
api/src/api_graphql/input/feature_flag_input.py
Normal file
18
api/src/api_graphql/input/feature_flag_input.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from api_graphql.abc.input_abc import InputABC
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlagInput(InputABC):
|
||||||
|
|
||||||
|
def __init__(self, src: dict):
|
||||||
|
InputABC.__init__(self, src)
|
||||||
|
|
||||||
|
self._key = self.option("key", str, required=True)
|
||||||
|
self._value = self.option("value", bool, required=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> str:
|
||||||
|
return self._key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self) -> bool:
|
||||||
|
return self._value
|
18
api/src/api_graphql/input/setting_input.py
Normal file
18
api/src/api_graphql/input/setting_input.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from api_graphql.abc.input_abc import InputABC
|
||||||
|
|
||||||
|
|
||||||
|
class SettingInput(InputABC):
|
||||||
|
|
||||||
|
def __init__(self, src: dict):
|
||||||
|
InputABC.__init__(self, src)
|
||||||
|
|
||||||
|
self._key = self.option("key", str, required=True)
|
||||||
|
self._value = self.option("value", str, required=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> str:
|
||||||
|
return self._key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self) -> str:
|
||||||
|
return self._value
|
18
api/src/api_graphql/input/user_setting_input.py
Normal file
18
api/src/api_graphql/input/user_setting_input.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from api_graphql.abc.input_abc import InputABC
|
||||||
|
|
||||||
|
|
||||||
|
class UserSettingInput(InputABC):
|
||||||
|
|
||||||
|
def __init__(self, src: dict):
|
||||||
|
InputABC.__init__(self, src)
|
||||||
|
|
||||||
|
self._key = self.option("key", str, required=True)
|
||||||
|
self._value = self.option("value", str, required=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> str:
|
||||||
|
return self._key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self) -> str:
|
||||||
|
return self._value
|
@ -60,3 +60,22 @@ class Mutation(MutationABC):
|
|||||||
Permissions.short_urls_delete,
|
Permissions.short_urls_delete,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.add_mutation_type(
|
||||||
|
"setting",
|
||||||
|
"Setting",
|
||||||
|
require_any_permission=[
|
||||||
|
Permissions.settings_update,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.add_mutation_type(
|
||||||
|
"userSetting",
|
||||||
|
"UserSetting",
|
||||||
|
)
|
||||||
|
self.add_mutation_type(
|
||||||
|
"featureFlag",
|
||||||
|
"FeatureFlag",
|
||||||
|
require_any_permission=[
|
||||||
|
Permissions.administrator,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
32
api/src/api_graphql/mutations/feature_flag_mutation.py
Normal file
32
api/src/api_graphql/mutations/feature_flag_mutation.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from api_graphql.abc.mutation_abc import MutationABC
|
||||||
|
from api_graphql.input.feature_flag_input import FeatureFlagInput
|
||||||
|
from core.logger import APILogger
|
||||||
|
from data.schemas.system.feature_flag import FeatureFlag
|
||||||
|
from data.schemas.system.feature_flag_dao import featureFlagDao
|
||||||
|
from service.permission.permissions_enum import Permissions
|
||||||
|
|
||||||
|
logger = APILogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlagMutation(MutationABC):
|
||||||
|
def __init__(self):
|
||||||
|
MutationABC.__init__(self, "FeatureFlag")
|
||||||
|
self.mutation(
|
||||||
|
"change",
|
||||||
|
self.resolve_change,
|
||||||
|
FeatureFlagInput,
|
||||||
|
require_any_permission=[Permissions.administrator],
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def resolve_change(obj: FeatureFlagInput, *_):
|
||||||
|
logger.debug(f"create new feature flag: {input}")
|
||||||
|
|
||||||
|
setting = await featureFlagDao.find_single_by({FeatureFlag.key: obj.key})
|
||||||
|
if setting is None:
|
||||||
|
raise ValueError(f"FeatureFlag {obj.key} not found")
|
||||||
|
|
||||||
|
setting.value = obj.value
|
||||||
|
await featureFlagDao.update(setting)
|
||||||
|
|
||||||
|
return await featureFlagDao.get_by_id(setting.id)
|
32
api/src/api_graphql/mutations/setting_mutation.py
Normal file
32
api/src/api_graphql/mutations/setting_mutation.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from api_graphql.abc.mutation_abc import MutationABC
|
||||||
|
from api_graphql.input.setting_input import SettingInput
|
||||||
|
from core.logger import APILogger
|
||||||
|
from data.schemas.system.setting import Setting
|
||||||
|
from data.schemas.system.setting_dao import settingsDao
|
||||||
|
from service.permission.permissions_enum import Permissions
|
||||||
|
|
||||||
|
logger = APILogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingMutation(MutationABC):
|
||||||
|
def __init__(self):
|
||||||
|
MutationABC.__init__(self, "Setting")
|
||||||
|
self.mutation(
|
||||||
|
"change",
|
||||||
|
self.resolve_change,
|
||||||
|
SettingInput,
|
||||||
|
require_any_permission=[Permissions.settings_update],
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def resolve_change(obj: SettingInput, *_):
|
||||||
|
logger.debug(f"create new setting: {input}")
|
||||||
|
|
||||||
|
setting = await settingsDao.find_single_by({Setting.key: obj.key})
|
||||||
|
if setting is None:
|
||||||
|
raise ValueError(f"Setting with key {obj.key} not found")
|
||||||
|
|
||||||
|
setting.value = obj.value
|
||||||
|
await settingsDao.update(setting)
|
||||||
|
|
||||||
|
return await settingsDao.get_by_id(setting.id)
|
@ -8,6 +8,8 @@ from data.schemas.public.domain_dao import domainDao
|
|||||||
from data.schemas.public.group_dao import groupDao
|
from data.schemas.public.group_dao import groupDao
|
||||||
from data.schemas.public.short_url import ShortUrl
|
from data.schemas.public.short_url import ShortUrl
|
||||||
from data.schemas.public.short_url_dao import shortUrlDao
|
from data.schemas.public.short_url_dao import shortUrlDao
|
||||||
|
from data.schemas.public.short_url_visit import ShortUrlVisit
|
||||||
|
from data.schemas.public.short_url_visit_dao import shortUrlVisitDao
|
||||||
from service.permission.permissions_enum import Permissions
|
from service.permission.permissions_enum import Permissions
|
||||||
|
|
||||||
logger = APILogger(__name__)
|
logger = APILogger(__name__)
|
||||||
@ -39,6 +41,11 @@ class ShortUrlMutation(MutationABC):
|
|||||||
self.resolve_restore,
|
self.resolve_restore,
|
||||||
require_any_permission=[Permissions.short_urls_delete],
|
require_any_permission=[Permissions.short_urls_delete],
|
||||||
)
|
)
|
||||||
|
self.mutation(
|
||||||
|
"trackVisit",
|
||||||
|
self.resolve_track_visit,
|
||||||
|
require_any_permission=[Permissions.short_urls_update],
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def resolve_create(obj: ShortUrlCreateInput, *_):
|
async def resolve_create(obj: ShortUrlCreateInput, *_):
|
||||||
@ -106,3 +113,9 @@ class ShortUrlMutation(MutationABC):
|
|||||||
short_url = await shortUrlDao.get_by_id(id)
|
short_url = await shortUrlDao.get_by_id(id)
|
||||||
await shortUrlDao.restore(short_url)
|
await shortUrlDao.restore(short_url)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def resolve_track_visit(*_, id: int, agent: str):
|
||||||
|
logger.debug(f"track visit: {id} -- {agent}")
|
||||||
|
await shortUrlVisitDao.create(ShortUrlVisit(0, id, agent))
|
||||||
|
return True
|
||||||
|
40
api/src/api_graphql/mutations/user_setting_mutation.py
Normal file
40
api/src/api_graphql/mutations/user_setting_mutation.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from api.route import Route
|
||||||
|
from api_graphql.abc.mutation_abc import MutationABC
|
||||||
|
from api_graphql.input.user_setting_input import UserSettingInput
|
||||||
|
from core.logger import APILogger
|
||||||
|
from data.schemas.public.user_setting import UserSetting
|
||||||
|
from data.schemas.public.user_setting_dao import userSettingsDao
|
||||||
|
from data.schemas.system.setting_dao import settingsDao
|
||||||
|
from service.permission.permissions_enum import Permissions
|
||||||
|
|
||||||
|
logger = APILogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UserSettingMutation(MutationABC):
|
||||||
|
def __init__(self):
|
||||||
|
MutationABC.__init__(self, "UserSetting")
|
||||||
|
self.mutation(
|
||||||
|
"change",
|
||||||
|
self.resolve_change,
|
||||||
|
UserSettingInput,
|
||||||
|
require_any_permission=[Permissions.settings_update],
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def resolve_change(obj: UserSettingInput, *_):
|
||||||
|
logger.debug(f"create new setting: {input}")
|
||||||
|
user = await Route.get_user_or_default()
|
||||||
|
if user is None:
|
||||||
|
logger.debug("user not authorized")
|
||||||
|
return None
|
||||||
|
|
||||||
|
setting = await userSettingsDao.find_single_by(
|
||||||
|
[{UserSetting.user_id: user.id}, {UserSetting.key: obj.key}]
|
||||||
|
)
|
||||||
|
if setting is None:
|
||||||
|
await userSettingsDao.create(UserSetting(0, user.id, obj.key, obj.value))
|
||||||
|
else:
|
||||||
|
setting.value = obj.value
|
||||||
|
await userSettingsDao.update(setting)
|
||||||
|
|
||||||
|
return await userSettingsDao.find_by_key(user, obj.key)
|
@ -16,9 +16,12 @@ class GroupQuery(DbModelQueryABC):
|
|||||||
self.field(
|
self.field(
|
||||||
ResolverFieldBuilder("shortUrls")
|
ResolverFieldBuilder("shortUrls")
|
||||||
.with_resolver(self._get_urls)
|
.with_resolver(self._get_urls)
|
||||||
.with_require_any([
|
.with_require_any(
|
||||||
Permissions.groups,
|
[
|
||||||
], [group_by_assignment_resolver])
|
Permissions.groups,
|
||||||
|
],
|
||||||
|
[group_by_assignment_resolver],
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self.set_field("roles", self._get_roles)
|
self.set_field("roles", self._get_roles)
|
||||||
|
|
||||||
|
@ -26,6 +26,10 @@ from data.schemas.public.group import Group
|
|||||||
from data.schemas.public.group_dao import groupDao
|
from data.schemas.public.group_dao import groupDao
|
||||||
from data.schemas.public.short_url import ShortUrl
|
from data.schemas.public.short_url import ShortUrl
|
||||||
from data.schemas.public.short_url_dao import shortUrlDao
|
from data.schemas.public.short_url_dao import shortUrlDao
|
||||||
|
from data.schemas.public.user_setting import UserSetting
|
||||||
|
from data.schemas.public.user_setting_dao import userSettingsDao
|
||||||
|
from data.schemas.system.feature_flag_dao import featureFlagDao
|
||||||
|
from data.schemas.system.setting_dao import settingsDao
|
||||||
from service.permission.permissions_enum import Permissions
|
from service.permission.permissions_enum import Permissions
|
||||||
|
|
||||||
|
|
||||||
@ -116,7 +120,7 @@ class Query(QueryABC):
|
|||||||
Permissions.short_urls_create,
|
Permissions.short_urls_create,
|
||||||
Permissions.short_urls_update,
|
Permissions.short_urls_update,
|
||||||
],
|
],
|
||||||
[group_by_assignment_resolver]
|
[group_by_assignment_resolver],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.field(
|
self.field(
|
||||||
@ -127,6 +131,23 @@ class Query(QueryABC):
|
|||||||
.with_require_any([Permissions.short_urls], [group_by_assignment_resolver])
|
.with_require_any([Permissions.short_urls], [group_by_assignment_resolver])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.field(
|
||||||
|
ResolverFieldBuilder("settings")
|
||||||
|
.with_resolver(self._resolve_settings)
|
||||||
|
.with_direct_result()
|
||||||
|
.with_public(True)
|
||||||
|
)
|
||||||
|
self.field(
|
||||||
|
ResolverFieldBuilder("userSettings")
|
||||||
|
.with_resolver(self._resolve_user_settings)
|
||||||
|
.with_direct_result()
|
||||||
|
)
|
||||||
|
self.field(
|
||||||
|
ResolverFieldBuilder("featureFlags")
|
||||||
|
.with_resolver(self._resolve_feature_flags)
|
||||||
|
.with_direct_result()
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _get_user(*_):
|
async def _get_user(*_):
|
||||||
return await Route.get_user()
|
return await Route.get_user()
|
||||||
@ -157,3 +178,27 @@ class Query(QueryABC):
|
|||||||
for x in kc_users
|
for x in kc_users
|
||||||
if x["id"] not in existing_user_keycloak_ids
|
if x["id"] not in existing_user_keycloak_ids
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _resolve_settings(*args, **kwargs):
|
||||||
|
if "key" in kwargs:
|
||||||
|
return [await settingsDao.find_by_key(kwargs["key"])]
|
||||||
|
return await settingsDao.get_all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _resolve_user_settings(*args, **kwargs):
|
||||||
|
user = await Route.get_user()
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "key" in kwargs:
|
||||||
|
return await userSettingsDao.find_by(
|
||||||
|
{UserSetting.user_id: user.id, UserSetting.key: kwargs["key"]}
|
||||||
|
)
|
||||||
|
return await userSettingsDao.find_by({UserSetting.user_id: user.id})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _resolve_feature_flags(*args, **kwargs):
|
||||||
|
if "key" in kwargs:
|
||||||
|
return [await featureFlagDao.find_by_key(kwargs["key"])]
|
||||||
|
return await featureFlagDao.get_all()
|
||||||
|
@ -12,11 +12,19 @@ async def group_by_assignment_resolver(ctx: QueryContext) -> bool:
|
|||||||
groups = [await x.group for x in ctx.data.nodes]
|
groups = [await x.group for x in ctx.data.nodes]
|
||||||
role_ids = {x.id for x in await ctx.user.roles}
|
role_ids = {x.id for x in await ctx.user.roles}
|
||||||
filtered_groups = [
|
filtered_groups = [
|
||||||
g.id for g in groups if
|
g.id
|
||||||
g is not None and (roles := await groupDao.get_roles(g.id)) and all(r.id in role_ids for r in roles)
|
for g in groups
|
||||||
|
if g is not None
|
||||||
|
and (roles := await groupDao.get_roles(g.id))
|
||||||
|
and all(r.id in role_ids for r in roles)
|
||||||
]
|
]
|
||||||
|
|
||||||
ctx.data.nodes = [node for node in ctx.data.nodes if (await node.group) is not None and (await node.group).id in filtered_groups]
|
ctx.data.nodes = [
|
||||||
|
node
|
||||||
|
for node in ctx.data.nodes
|
||||||
|
if (await node.group) is not None
|
||||||
|
and (await node.group).id in filtered_groups
|
||||||
|
]
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
@ -5,6 +5,7 @@ from ariadne import make_executable_schema, load_schema_from_path
|
|||||||
from api_graphql.definition import QUERIES
|
from api_graphql.definition import QUERIES
|
||||||
from api_graphql.mutation import Mutation
|
from api_graphql.mutation import Mutation
|
||||||
from api_graphql.query import Query
|
from api_graphql.query import Query
|
||||||
|
from api_graphql.subscription import Subscription
|
||||||
|
|
||||||
type_defs = load_schema_from_path(
|
type_defs = load_schema_from_path(
|
||||||
os.path.join(os.path.dirname(os.path.realpath(__file__)), "../graphql/")
|
os.path.join(os.path.dirname(os.path.realpath(__file__)), "../graphql/")
|
||||||
@ -13,5 +14,6 @@ schema = make_executable_schema(
|
|||||||
type_defs,
|
type_defs,
|
||||||
Query(),
|
Query(),
|
||||||
Mutation(),
|
Mutation(),
|
||||||
|
Subscription(),
|
||||||
*QUERIES,
|
*QUERIES,
|
||||||
)
|
)
|
||||||
|
66
api/src/api_graphql/subscription.py
Normal file
66
api/src/api_graphql/subscription.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
from api_graphql.abc.subscription_abc import SubscriptionABC
|
||||||
|
from api_graphql.field.subscription_field_builder import SubscriptionFieldBuilder
|
||||||
|
from service.permission.permissions_enum import Permissions
|
||||||
|
|
||||||
|
|
||||||
|
class Subscription(SubscriptionABC):
|
||||||
|
def __init__(self):
|
||||||
|
SubscriptionABC.__init__(self)
|
||||||
|
|
||||||
|
self.subscribe(
|
||||||
|
SubscriptionFieldBuilder("ping")
|
||||||
|
.with_resolver(lambda message, *_: message.message)
|
||||||
|
.with_public(True)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.subscribe(
|
||||||
|
SubscriptionFieldBuilder("apiKeyChange")
|
||||||
|
.with_resolver(lambda message, *_: message.message)
|
||||||
|
.with_require_any_permission([Permissions.api_keys])
|
||||||
|
)
|
||||||
|
|
||||||
|
self.subscribe(
|
||||||
|
SubscriptionFieldBuilder("featureFlagChange")
|
||||||
|
.with_resolver(lambda message, *_: message.message)
|
||||||
|
.with_public(True)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.subscribe(
|
||||||
|
SubscriptionFieldBuilder("roleChange")
|
||||||
|
.with_resolver(lambda message, *_: message.message)
|
||||||
|
.with_require_any_permission([Permissions.roles])
|
||||||
|
)
|
||||||
|
|
||||||
|
self.subscribe(
|
||||||
|
SubscriptionFieldBuilder("settingChange")
|
||||||
|
.with_resolver(lambda message, *_: message.message)
|
||||||
|
.with_require_any_permission([Permissions.settings])
|
||||||
|
)
|
||||||
|
|
||||||
|
self.subscribe(
|
||||||
|
SubscriptionFieldBuilder("userChange")
|
||||||
|
.with_resolver(lambda message, *_: message.message)
|
||||||
|
.with_require_any_permission([Permissions.users])
|
||||||
|
)
|
||||||
|
|
||||||
|
self.subscribe(
|
||||||
|
SubscriptionFieldBuilder("userSettingChange")
|
||||||
|
.with_resolver(lambda message, *_: message.message)
|
||||||
|
.with_public(True)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.subscribe(
|
||||||
|
SubscriptionFieldBuilder("domainChange")
|
||||||
|
.with_resolver(lambda message, *_: message.message)
|
||||||
|
.with_require_any_permission([Permissions.domains])
|
||||||
|
)
|
||||||
|
self.subscribe(
|
||||||
|
SubscriptionFieldBuilder("groupChange")
|
||||||
|
.with_resolver(lambda message, *_: message.message)
|
||||||
|
.with_require_any_permission([Permissions.groups])
|
||||||
|
)
|
||||||
|
self.subscribe(
|
||||||
|
SubscriptionFieldBuilder("shortUrlChange")
|
||||||
|
.with_resolver(lambda message, *_: message.message)
|
||||||
|
.with_require_any_permission([Permissions.short_urls])
|
||||||
|
)
|
@ -9,7 +9,7 @@ TRequireAnyResolvers = list[
|
|||||||
Union[
|
Union[
|
||||||
Callable[[QueryContext], bool],
|
Callable[[QueryContext], bool],
|
||||||
Awaitable[[QueryContext], bool],
|
Awaitable[[QueryContext], bool],
|
||||||
Callable[[QueryContext], Coroutine[Any, Any, bool]]
|
Callable[[QueryContext], Coroutine[Any, Any, bool]],
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
TRequireAny = tuple[TRequireAnyPermissions, TRequireAnyResolvers]
|
TRequireAny = tuple[TRequireAnyPermissions, TRequireAnyResolvers]
|
||||||
|
0
api/src/core/configuration/__init__.py
Normal file
0
api/src/core/configuration/__init__.py
Normal file
20
api/src/core/configuration/feature_flags.py
Normal file
20
api/src/core/configuration/feature_flags.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from core.configuration.feature_flags_enum import FeatureFlagsEnum
|
||||||
|
from data.schemas.system.feature_flag_dao import featureFlagDao
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlags:
|
||||||
|
_flags = {
|
||||||
|
FeatureFlagsEnum.version_endpoint.value: True, # 15.01.2025
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_default(key: FeatureFlagsEnum) -> bool:
|
||||||
|
return FeatureFlags._flags[key.value]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def has_feature(key: FeatureFlagsEnum) -> bool:
|
||||||
|
value = await featureFlagDao.find_by_key(key.value)
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return value.value
|
6
api/src/core/configuration/feature_flags_enum.py
Normal file
6
api/src/core/configuration/feature_flags_enum.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlagsEnum(Enum):
|
||||||
|
# modules
|
||||||
|
version_endpoint = "VersionEndpoint"
|
1
api/src/core/const.py
Normal file
1
api/src/core/const.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S.%f %z"
|
@ -4,9 +4,12 @@ from enum import Enum
|
|||||||
from types import NoneType
|
from types import NoneType
|
||||||
from typing import Generic, Optional, Union, TypeVar, Any, Type
|
from typing import Generic, Optional, Union, TypeVar, Any, Type
|
||||||
|
|
||||||
|
from core.const import DATETIME_FORMAT
|
||||||
from core.database.abc.db_model_abc import DbModelABC
|
from core.database.abc.db_model_abc import DbModelABC
|
||||||
from core.database.database import Database
|
from core.database.database import Database
|
||||||
|
from core.get_value import get_value
|
||||||
from core.logger import DBLogger
|
from core.logger import DBLogger
|
||||||
|
from core.string import camel_to_snake
|
||||||
from core.typing import T, Attribute, AttributeFilters, AttributeSorts
|
from core.typing import T, Attribute, AttributeFilters, AttributeSorts
|
||||||
|
|
||||||
T_DBM = TypeVar("T_DBM", bound=DbModelABC)
|
T_DBM = TypeVar("T_DBM", bound=DbModelABC)
|
||||||
@ -23,7 +26,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
|
|||||||
self._default_filter_condition = None
|
self._default_filter_condition = None
|
||||||
|
|
||||||
self.__attributes: dict[str, type] = {}
|
self.__attributes: dict[str, type] = {}
|
||||||
|
self.__joins: dict[str, str] = {}
|
||||||
|
|
||||||
self.__db_names: dict[str, str] = {}
|
self.__db_names: dict[str, str] = {}
|
||||||
|
self.__foreign_tables: dict[str, str] = {}
|
||||||
|
|
||||||
self.__date_attributes: set[str] = set()
|
self.__date_attributes: set[str] = set()
|
||||||
self.__ignored_attributes: set[str] = set()
|
self.__ignored_attributes: set[str] = set()
|
||||||
|
|
||||||
@ -35,12 +42,12 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
|
|||||||
return self._table_name
|
return self._table_name
|
||||||
|
|
||||||
def attribute(
|
def attribute(
|
||||||
self,
|
self,
|
||||||
attr_name: Attribute,
|
attr_name: Attribute,
|
||||||
attr_type: type,
|
attr_type: type,
|
||||||
db_name: str = None,
|
db_name: str = None,
|
||||||
ignore=False,
|
ignore=False,
|
||||||
primary_key=False,
|
primary_key=False,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Add an attribute for db and object mapping to the data access object
|
Add an attribute for db and object mapping to the data access object
|
||||||
@ -69,6 +76,40 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
|
|||||||
if attr_type in [datetime, datetime.datetime]:
|
if attr_type in [datetime, datetime.datetime]:
|
||||||
self.__date_attributes.add(db_name)
|
self.__date_attributes.add(db_name)
|
||||||
|
|
||||||
|
def reference(
|
||||||
|
self,
|
||||||
|
attr: Attribute,
|
||||||
|
primary_attr: Attribute,
|
||||||
|
foreign_attr: Attribute,
|
||||||
|
table_name: str,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Add a reference to another table for the given attribute
|
||||||
|
:param str primary_attr: Name of the primary key in the foreign object
|
||||||
|
:param str foreign_attr: Name of the foreign key in the object
|
||||||
|
:param str table_name: Name of the table to reference
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
if table_name == self._table_name:
|
||||||
|
return
|
||||||
|
if isinstance(attr, property):
|
||||||
|
attr = attr.fget.__name__
|
||||||
|
|
||||||
|
if isinstance(primary_attr, property):
|
||||||
|
primary_attr = primary_attr.fget.__name__
|
||||||
|
|
||||||
|
primary_attr = primary_attr.lower().replace("_", "")
|
||||||
|
|
||||||
|
if isinstance(foreign_attr, property):
|
||||||
|
foreign_attr = foreign_attr.fget.__name__
|
||||||
|
|
||||||
|
foreign_attr = foreign_attr.lower().replace("_", "")
|
||||||
|
|
||||||
|
self.__joins[foreign_attr] = (
|
||||||
|
f"LEFT JOIN {table_name} ON {table_name}.{primary_attr} = {self._table_name}.{foreign_attr}"
|
||||||
|
)
|
||||||
|
self.__foreign_tables[attr] = table_name
|
||||||
|
|
||||||
def to_object(self, result: dict) -> T_DBM:
|
def to_object(self, result: dict) -> T_DBM:
|
||||||
"""
|
"""
|
||||||
Convert a result from the database to an object
|
Convert a result from the database to an object
|
||||||
@ -89,8 +130,13 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
|
|||||||
|
|
||||||
return self._model_type(**value_map)
|
return self._model_type(**value_map)
|
||||||
|
|
||||||
async def count(self) -> int:
|
async def count(self, filters: AttributeFilters = None) -> int:
|
||||||
result = await self._db.select_map(f"SELECT COUNT(*) FROM {self._table_name}")
|
query = f"SELECT COUNT(*) FROM {self._table_name}"
|
||||||
|
|
||||||
|
if filters is not None and (not isinstance(filters, list) or len(filters) > 0):
|
||||||
|
query += f" WHERE {self._build_conditions(filters)}"
|
||||||
|
|
||||||
|
result = await self._db.select_map(query)
|
||||||
return result[0]["count"]
|
return result[0]["count"]
|
||||||
|
|
||||||
async def get_all(self) -> list[T_DBM]:
|
async def get_all(self) -> list[T_DBM]:
|
||||||
@ -118,11 +164,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
|
|||||||
return self.to_object(result[0])
|
return self.to_object(result[0])
|
||||||
|
|
||||||
async def get_by(
|
async def get_by(
|
||||||
self,
|
self,
|
||||||
filters: AttributeFilters = None,
|
filters: AttributeFilters = None,
|
||||||
sorts: AttributeSorts = None,
|
sorts: AttributeSorts = None,
|
||||||
take: int = None,
|
take: int = None,
|
||||||
skip: int = None,
|
skip: int = None,
|
||||||
) -> list[T_DBM]:
|
) -> list[T_DBM]:
|
||||||
"""
|
"""
|
||||||
Get all objects by the given filters
|
Get all objects by the given filters
|
||||||
@ -143,11 +189,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
|
|||||||
return [self.to_object(x) for x in result]
|
return [self.to_object(x) for x in result]
|
||||||
|
|
||||||
async def get_single_by(
|
async def get_single_by(
|
||||||
self,
|
self,
|
||||||
filters: AttributeFilters = None,
|
filters: AttributeFilters = None,
|
||||||
sorts: AttributeSorts = None,
|
sorts: AttributeSorts = None,
|
||||||
take: int = None,
|
take: int = None,
|
||||||
skip: int = None,
|
skip: int = None,
|
||||||
) -> T_DBM:
|
) -> T_DBM:
|
||||||
"""
|
"""
|
||||||
Get a single object by the given filters
|
Get a single object by the given filters
|
||||||
@ -168,11 +214,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
|
|||||||
return result[0]
|
return result[0]
|
||||||
|
|
||||||
async def find_by(
|
async def find_by(
|
||||||
self,
|
self,
|
||||||
filters: AttributeFilters = None,
|
filters: AttributeFilters = None,
|
||||||
sorts: AttributeSorts = None,
|
sorts: AttributeSorts = None,
|
||||||
take: int = None,
|
take: int = None,
|
||||||
skip: int = None,
|
skip: int = None,
|
||||||
) -> list[Optional[T_DBM]]:
|
) -> list[Optional[T_DBM]]:
|
||||||
"""
|
"""
|
||||||
Find all objects by the given filters
|
Find all objects by the given filters
|
||||||
@ -192,11 +238,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
|
|||||||
return [self.to_object(x) for x in result]
|
return [self.to_object(x) for x in result]
|
||||||
|
|
||||||
async def find_single_by(
|
async def find_single_by(
|
||||||
self,
|
self,
|
||||||
filters: AttributeFilters = None,
|
filters: AttributeFilters = None,
|
||||||
sorts: AttributeSorts = None,
|
sorts: AttributeSorts = None,
|
||||||
take: int = None,
|
take: int = None,
|
||||||
skip: int = None,
|
skip: int = None,
|
||||||
) -> Optional[T_DBM]:
|
) -> Optional[T_DBM]:
|
||||||
"""
|
"""
|
||||||
Find a single object by the given filters
|
Find a single object by the given filters
|
||||||
@ -296,7 +342,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
|
|||||||
await self._db.execute(query)
|
await self._db.execute(query)
|
||||||
|
|
||||||
async def _build_delete_statement(
|
async def _build_delete_statement(
|
||||||
self, obj: T_DBM, hard_delete: bool = False
|
self, obj: T_DBM, hard_delete: bool = False
|
||||||
) -> str:
|
) -> str:
|
||||||
if hard_delete:
|
if hard_delete:
|
||||||
return f"""
|
return f"""
|
||||||
@ -384,6 +430,12 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
|
|||||||
return "ARRAY[]::text[]"
|
return "ARRAY[]::text[]"
|
||||||
return f"ARRAY[{", ".join([DataAccessObjectABC._get_value_sql(x) for x in value])}]"
|
return f"ARRAY[{", ".join([DataAccessObjectABC._get_value_sql(x) for x in value])}]"
|
||||||
|
|
||||||
|
if isinstance(value, datetime.datetime):
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
return f"'{value.strftime(DATETIME_FORMAT)}'"
|
||||||
|
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -406,13 +458,16 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
|
|||||||
return cast_type(value)
|
return cast_type(value)
|
||||||
|
|
||||||
def _build_conditional_query(
|
def _build_conditional_query(
|
||||||
self,
|
self,
|
||||||
filters: AttributeFilters = None,
|
filters: AttributeFilters = None,
|
||||||
sorts: AttributeSorts = None,
|
sorts: AttributeSorts = None,
|
||||||
take: int = None,
|
take: int = None,
|
||||||
skip: int = None,
|
skip: int = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
query = f"SELECT * FROM {self._table_name}"
|
query = f"SELECT {self._table_name}.* FROM {self._table_name}"
|
||||||
|
|
||||||
|
for join in self.__joins:
|
||||||
|
query += f" {self.__joins[join]}"
|
||||||
|
|
||||||
if filters is not None and (not isinstance(filters, list) or len(filters) > 0):
|
if filters is not None and (not isinstance(filters, list) or len(filters) > 0):
|
||||||
query += f" WHERE {self._build_conditions(filters)}"
|
query += f" WHERE {self._build_conditions(filters)}"
|
||||||
@ -438,12 +493,41 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
|
|||||||
for attr, values in f.items():
|
for attr, values in f.items():
|
||||||
if isinstance(attr, property):
|
if isinstance(attr, property):
|
||||||
attr = attr.fget.__name__
|
attr = attr.fget.__name__
|
||||||
|
|
||||||
|
if attr in self.__foreign_tables:
|
||||||
|
foreign_table = self.__foreign_tables[attr]
|
||||||
|
conditions.extend(
|
||||||
|
self._build_foreign_conditions(foreign_table, values)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if attr == "fuzzy":
|
||||||
|
conditions.append(
|
||||||
|
" OR ".join(
|
||||||
|
self._build_fuzzy_conditions(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
self.__db_names[x]
|
||||||
|
if x in self.__db_names
|
||||||
|
else self.__db_names[camel_to_snake(x)]
|
||||||
|
)
|
||||||
|
for x in get_value(values, "fields", list[str])
|
||||||
|
],
|
||||||
|
get_value(values, "term", str),
|
||||||
|
get_value(values, "threshold", int, 5),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
db_name = self.__db_names[attr]
|
db_name = self.__db_names[attr]
|
||||||
|
|
||||||
if isinstance(values, dict):
|
if isinstance(values, dict):
|
||||||
for operator, value in values.items():
|
for operator, value in values.items():
|
||||||
conditions.append(
|
conditions.append(
|
||||||
self._build_condition(db_name, operator, value)
|
self._build_condition(
|
||||||
|
f"{self._table_name}.{db_name}", operator, value
|
||||||
|
)
|
||||||
)
|
)
|
||||||
elif isinstance(values, list):
|
elif isinstance(values, list):
|
||||||
sub_conditions = []
|
sub_conditions = []
|
||||||
@ -451,7 +535,9 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
|
|||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
for operator, val in value.items():
|
for operator, val in value.items():
|
||||||
sub_conditions.append(
|
sub_conditions.append(
|
||||||
self._build_condition(db_name, operator, val)
|
self._build_condition(
|
||||||
|
f"{self._table_name}.{db_name}", operator, val
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
sub_conditions.append(
|
sub_conditions.append(
|
||||||
@ -463,12 +549,65 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
|
|||||||
|
|
||||||
return " AND ".join(conditions)
|
return " AND ".join(conditions)
|
||||||
|
|
||||||
|
def _build_fuzzy_conditions(
|
||||||
|
self, fields: list[str], term: str, threshold: int = 10
|
||||||
|
) -> list[str]:
|
||||||
|
conditions = []
|
||||||
|
for field in fields:
|
||||||
|
conditions.append(
|
||||||
|
f"levenshtein({field}, '{term}') <= {threshold}"
|
||||||
|
) # Adjust the threshold as needed
|
||||||
|
|
||||||
|
return conditions
|
||||||
|
|
||||||
|
def _build_foreign_conditions(self, table: str, values: dict) -> list[str]:
|
||||||
|
"""
|
||||||
|
Build SQL conditions for foreign key references
|
||||||
|
:param table: Foreign table name
|
||||||
|
:param values: Filter values
|
||||||
|
:return: List of conditions
|
||||||
|
"""
|
||||||
|
conditions = []
|
||||||
|
for attr, sub_values in values.items():
|
||||||
|
if isinstance(attr, property):
|
||||||
|
attr = attr.fget.__name__
|
||||||
|
|
||||||
|
if attr in self.__foreign_tables:
|
||||||
|
foreign_table = self.__foreign_tables[attr]
|
||||||
|
conditions.extend(
|
||||||
|
self._build_foreign_conditions(foreign_table, sub_values)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
db_name = f"{table}.{attr.lower().replace('_', '')}"
|
||||||
|
|
||||||
|
if isinstance(sub_values, dict):
|
||||||
|
for operator, value in sub_values.items():
|
||||||
|
conditions.append(self._build_condition(db_name, operator, value))
|
||||||
|
elif isinstance(sub_values, list):
|
||||||
|
sub_conditions = []
|
||||||
|
for value in sub_values:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for operator, val in value.items():
|
||||||
|
sub_conditions.append(
|
||||||
|
self._build_condition(db_name, operator, val)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sub_conditions.append(
|
||||||
|
self._get_value_validation_sql(db_name, value)
|
||||||
|
)
|
||||||
|
conditions.append(f"({' OR '.join(sub_conditions)})")
|
||||||
|
else:
|
||||||
|
conditions.append(self._get_value_validation_sql(db_name, sub_values))
|
||||||
|
|
||||||
|
return conditions
|
||||||
|
|
||||||
def _get_value_validation_sql(self, field: str, value: Any):
|
def _get_value_validation_sql(self, field: str, value: Any):
|
||||||
value = self._get_value_sql(value)
|
value = self._get_value_sql(value)
|
||||||
|
|
||||||
if value == "NULL":
|
if value == "NULL":
|
||||||
return f"{field} IS NULL"
|
return f"{self._table_name}.{field} IS NULL"
|
||||||
return f"{field} = {value}"
|
return f"{self._table_name}.{field} = {value}"
|
||||||
|
|
||||||
def _build_condition(self, db_name: str, operator: str, value: Any) -> str:
|
def _build_condition(self, db_name: str, operator: str, value: Any) -> str:
|
||||||
"""
|
"""
|
||||||
@ -530,6 +669,13 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
|
|||||||
if isinstance(attr, property):
|
if isinstance(attr, property):
|
||||||
attr = attr.fget.__name__
|
attr = attr.fget.__name__
|
||||||
|
|
||||||
|
if attr in self.__foreign_tables:
|
||||||
|
foreign_table = self.__foreign_tables[attr]
|
||||||
|
sort_clauses.extend(
|
||||||
|
self._build_foreign_order_by(foreign_table, direction)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
match attr:
|
match attr:
|
||||||
case "createdUtc":
|
case "createdUtc":
|
||||||
attr = "created"
|
attr = "created"
|
||||||
@ -547,6 +693,30 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
|
|||||||
|
|
||||||
return ", ".join(sort_clauses)
|
return ", ".join(sort_clauses)
|
||||||
|
|
||||||
|
def _build_foreign_order_by(self, table: str, direction: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
Build SQL order by clause for foreign key references
|
||||||
|
:param table: Foreign table name
|
||||||
|
:param direction: Sort direction
|
||||||
|
:return: List of order by clauses
|
||||||
|
"""
|
||||||
|
sort_clauses = []
|
||||||
|
for attr, sub_direction in direction.items():
|
||||||
|
if isinstance(attr, property):
|
||||||
|
attr = attr.fget.__name__
|
||||||
|
|
||||||
|
if attr in self.__foreign_tables:
|
||||||
|
foreign_table = self.__foreign_tables[attr]
|
||||||
|
sort_clauses.extend(
|
||||||
|
self._build_foreign_order_by(foreign_table, sub_direction)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
db_name = f"{table}.{attr.lower().replace('_', '')}"
|
||||||
|
sort_clauses.append(f"{db_name} {sub_direction.upper()}")
|
||||||
|
|
||||||
|
return sort_clauses
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _get_editor_id(obj: T_DBM):
|
async def _get_editor_id(obj: T_DBM):
|
||||||
editor_id = obj.editor_id
|
editor_id = obj.editor_id
|
||||||
|
@ -4,11 +4,11 @@ from core.typing import T
|
|||||||
|
|
||||||
|
|
||||||
def get_value(
|
def get_value(
|
||||||
source: dict,
|
source: dict,
|
||||||
key: str,
|
key: str,
|
||||||
cast_type: Type[T],
|
cast_type: Type[T],
|
||||||
default: Optional[T] = None,
|
default: Optional[T] = None,
|
||||||
list_delimiter: str = ",",
|
list_delimiter: str = ",",
|
||||||
) -> Optional[T]:
|
) -> Optional[T]:
|
||||||
"""
|
"""
|
||||||
Get value from source dictionary and cast it to a specified type.
|
Get value from source dictionary and cast it to a specified type.
|
||||||
@ -26,8 +26,8 @@ def get_value(
|
|||||||
|
|
||||||
value = source[key]
|
value = source[key]
|
||||||
if isinstance(
|
if isinstance(
|
||||||
value,
|
value,
|
||||||
cast_type if not hasattr(cast_type, "__origin__") else cast_type.__origin__,
|
cast_type if not hasattr(cast_type, "__origin__") else cast_type.__origin__,
|
||||||
):
|
):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@ -36,10 +36,15 @@ def get_value(
|
|||||||
return value.lower() in ["true", "1"]
|
return value.lower() in ["true", "1"]
|
||||||
|
|
||||||
if (
|
if (
|
||||||
cast_type if not hasattr(cast_type, "__origin__") else cast_type.__origin__
|
cast_type if not hasattr(cast_type, "__origin__") else cast_type.__origin__
|
||||||
) == list:
|
) == list:
|
||||||
if not (value.startswith("[") and value.endswith("]")) and list_delimiter not in value:
|
if (
|
||||||
raise ValueError("List values must be enclosed in square brackets or use a delimiter.")
|
not (value.startswith("[") and value.endswith("]"))
|
||||||
|
and list_delimiter not in value
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"List values must be enclosed in square brackets or use a delimiter."
|
||||||
|
)
|
||||||
|
|
||||||
if value.startswith("[") and value.endswith("]"):
|
if value.startswith("[") and value.endswith("]"):
|
||||||
value = value[1:-1]
|
value = value[1:-1]
|
||||||
|
@ -1,8 +1,13 @@
|
|||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
from api.middleware.request import get_request
|
||||||
|
from core.environment import Environment
|
||||||
|
|
||||||
|
|
||||||
class Logger:
|
class Logger:
|
||||||
_level = "info"
|
_level = "info"
|
||||||
_levels = ["trace", "debug", "info", "warning", "error", "fatal"]
|
_levels = ["trace", "debug", "info", "warning", "error", "fatal"]
|
||||||
@ -54,6 +59,30 @@ class Logger:
|
|||||||
else:
|
else:
|
||||||
raise ValueError(f"Invalid log level: {level}")
|
raise ValueError(f"Invalid log level: {level}")
|
||||||
|
|
||||||
|
def _get_structured_message(self, level: str, timestamp: str, messages: str) -> str:
|
||||||
|
structured_message = {
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"level": level.upper(),
|
||||||
|
"source": self.source,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
request = get_request()
|
||||||
|
|
||||||
|
if request is not None:
|
||||||
|
structured_message["request"] = {
|
||||||
|
"url": str(request.url),
|
||||||
|
"method": request.method,
|
||||||
|
"data": asyncio.create_task(request.body()),
|
||||||
|
}
|
||||||
|
return str(structured_message)
|
||||||
|
|
||||||
|
def _write_log_to_file(self, content: str):
|
||||||
|
self._ensure_file_size()
|
||||||
|
with open(self.log_file, "a") as log_file:
|
||||||
|
log_file.write(content + "\n")
|
||||||
|
log_file.close()
|
||||||
|
|
||||||
def _log(self, level: str, *messages):
|
def _log(self, level: str, *messages):
|
||||||
try:
|
try:
|
||||||
if self._levels.index(level) < self._levels.index(self._level):
|
if self._levels.index(level) < self._levels.index(self._level):
|
||||||
@ -63,17 +92,18 @@ class Logger:
|
|||||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||||
formatted_message = f"<{timestamp}> [{level.upper():^7}] [{self._file_prefix:^5}] - [{self.source}]: {' '.join(messages)}"
|
formatted_message = f"<{timestamp}> [{level.upper():^7}] [{self._file_prefix:^5}] - [{self.source}]: {' '.join(messages)}"
|
||||||
|
|
||||||
self._ensure_file_size()
|
if Environment.get("STRUCTURED_LOGGING", bool, False):
|
||||||
with open(self.log_file, "a") as log_file:
|
self._write_log_to_file(
|
||||||
log_file.write(formatted_message + "\n")
|
self._get_structured_message(level, timestamp, " ".join(messages))
|
||||||
log_file.close()
|
)
|
||||||
|
else:
|
||||||
|
self._write_log_to_file(formatted_message)
|
||||||
|
|
||||||
color = self.COLORS.get(level, self.COLORS["reset"])
|
print(
|
||||||
reset_color = self.COLORS["reset"]
|
f"{self.COLORS.get(level, self.COLORS["reset"])}{formatted_message}{self.COLORS["reset"]}"
|
||||||
|
)
|
||||||
print(f"{color}{formatted_message}{reset_color}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error while logging: {e}")
|
print(f"Error while logging: {e} -> {traceback.format_exc()}")
|
||||||
|
|
||||||
def trace(self, *messages):
|
def trace(self, *messages):
|
||||||
self._log("trace", *messages)
|
self._log("trace", *messages)
|
||||||
|
9
api/src/core/string.py
Normal file
9
api/src/core/string.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def first_to_lower(s: str) -> str:
|
||||||
|
return s[0].lower() + s[1:] if s else s
|
||||||
|
|
||||||
|
|
||||||
|
def camel_to_snake(s: str) -> str:
|
||||||
|
return re.sub(r"(?<!^)(?=[A-Z])", "_", s).lower()
|
48
api/src/data/schemas/public/user_setting.py
Normal file
48
api/src/data/schemas/public/user_setting.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
from async_property import async_property
|
||||||
|
|
||||||
|
from core.database.abc.db_model_abc import DbModelABC
|
||||||
|
from core.typing import SerialId
|
||||||
|
|
||||||
|
|
||||||
|
class UserSetting(DbModelABC):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
id: SerialId,
|
||||||
|
user_id: SerialId,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
deleted: bool = False,
|
||||||
|
editor_id: Optional[SerialId] = None,
|
||||||
|
created: Optional[datetime] = None,
|
||||||
|
updated: Optional[datetime] = None,
|
||||||
|
):
|
||||||
|
DbModelABC.__init__(self, id, deleted, editor_id, created, updated)
|
||||||
|
|
||||||
|
self._user_id = user_id
|
||||||
|
self._key = key
|
||||||
|
self._value = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_id(self) -> SerialId:
|
||||||
|
return self._user_id
|
||||||
|
|
||||||
|
@async_property
|
||||||
|
async def user(self):
|
||||||
|
from data.schemas.administration.user_dao import userDao
|
||||||
|
|
||||||
|
return await userDao.get_by_id(self._user_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> str:
|
||||||
|
return self._key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self) -> str:
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
@value.setter
|
||||||
|
def value(self, value: Union[str, int, float, bool]):
|
||||||
|
self._value = str(value)
|
24
api/src/data/schemas/public/user_setting_dao.py
Normal file
24
api/src/data/schemas/public/user_setting_dao.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
from core.database.abc.db_model_dao_abc import DbModelDaoABC
|
||||||
|
from core.logger import DBLogger
|
||||||
|
from data.schemas.administration.user import User
|
||||||
|
from data.schemas.public.user_setting import UserSetting
|
||||||
|
|
||||||
|
logger = DBLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UserSettingDao(DbModelDaoABC[UserSetting]):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
DbModelDaoABC.__init__(self, __name__, UserSetting, "public.user_settings")
|
||||||
|
|
||||||
|
self.attribute(UserSetting.user_id, int)
|
||||||
|
self.attribute(UserSetting.key, str)
|
||||||
|
self.attribute(UserSetting.value, str)
|
||||||
|
|
||||||
|
async def find_by_key(self, user: User, key: str) -> UserSetting:
|
||||||
|
return await self.find_single_by(
|
||||||
|
[{UserSetting.user_id: user.id}, {UserSetting.key: key}]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
userSettingsDao = UserSettingDao()
|
34
api/src/data/schemas/system/feature_flag.py
Normal file
34
api/src/data/schemas/system/feature_flag.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from core.database.abc.db_model_abc import DbModelABC
|
||||||
|
from core.typing import SerialId
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlag(DbModelABC):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
id: SerialId,
|
||||||
|
key: str,
|
||||||
|
value: bool,
|
||||||
|
deleted: bool = False,
|
||||||
|
editor_id: Optional[SerialId] = None,
|
||||||
|
created: Optional[datetime] = None,
|
||||||
|
updated: Optional[datetime] = None,
|
||||||
|
):
|
||||||
|
DbModelABC.__init__(self, id, deleted, editor_id, created, updated)
|
||||||
|
|
||||||
|
self._key = key
|
||||||
|
self._value = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> str:
|
||||||
|
return self._key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self) -> bool:
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
@value.setter
|
||||||
|
def value(self, value: bool):
|
||||||
|
self._value = value
|
20
api/src/data/schemas/system/feature_flag_dao.py
Normal file
20
api/src/data/schemas/system/feature_flag_dao.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from core.database.abc.db_model_dao_abc import DbModelDaoABC
|
||||||
|
from core.logger import DBLogger
|
||||||
|
from data.schemas.system.feature_flag import FeatureFlag
|
||||||
|
|
||||||
|
logger = DBLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlagDao(DbModelDaoABC[FeatureFlag]):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
DbModelDaoABC.__init__(self, __name__, FeatureFlag, "system.feature_flags")
|
||||||
|
|
||||||
|
self.attribute(FeatureFlag.key, str)
|
||||||
|
self.attribute(FeatureFlag.value, bool)
|
||||||
|
|
||||||
|
async def find_by_key(self, key: str) -> FeatureFlag:
|
||||||
|
return await self.find_single_by({FeatureFlag.key: key})
|
||||||
|
|
||||||
|
|
||||||
|
featureFlagDao = FeatureFlagDao()
|
34
api/src/data/schemas/system/setting.py
Normal file
34
api/src/data/schemas/system/setting.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
from core.database.abc.db_model_abc import DbModelABC
|
||||||
|
from core.typing import SerialId
|
||||||
|
|
||||||
|
|
||||||
|
class Setting(DbModelABC):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
id: SerialId,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
deleted: bool = False,
|
||||||
|
editor_id: Optional[SerialId] = None,
|
||||||
|
created: Optional[datetime] = None,
|
||||||
|
updated: Optional[datetime] = None,
|
||||||
|
):
|
||||||
|
DbModelABC.__init__(self, id, deleted, editor_id, created, updated)
|
||||||
|
|
||||||
|
self._key = key
|
||||||
|
self._value = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> str:
|
||||||
|
return self._key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self) -> str:
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
@value.setter
|
||||||
|
def value(self, value: Union[str, int, float, bool]):
|
||||||
|
self._value = str(value)
|
20
api/src/data/schemas/system/setting_dao.py
Normal file
20
api/src/data/schemas/system/setting_dao.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from core.database.abc.db_model_dao_abc import DbModelDaoABC
|
||||||
|
from core.logger import DBLogger
|
||||||
|
from data.schemas.system.setting import Setting
|
||||||
|
|
||||||
|
logger = DBLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingDao(DbModelDaoABC[Setting]):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
DbModelDaoABC.__init__(self, __name__, Setting, "system.settings")
|
||||||
|
|
||||||
|
self.attribute(Setting.key, str)
|
||||||
|
self.attribute(Setting.value, str)
|
||||||
|
|
||||||
|
async def find_by_key(self, key: str) -> Setting:
|
||||||
|
return await self.find_single_by({Setting.key: key})
|
||||||
|
|
||||||
|
|
||||||
|
settingsDao = SettingDao()
|
24
api/src/data/scripts/2025-03-08-08-10-settings.sql
Normal file
24
api/src/data/scripts/2025-03-08-08-10-settings.sql
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
CREATE SCHEMA IF NOT EXISTS system;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS system.settings
|
||||||
|
(
|
||||||
|
Id SERIAL PRIMARY KEY,
|
||||||
|
Key TEXT NOT NULL,
|
||||||
|
Value TEXT NOT NULL,
|
||||||
|
-- for history
|
||||||
|
Deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
EditorId INT NULL REFERENCES administration.users (Id),
|
||||||
|
CreatedUtc timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
UpdatedUtc timestamptz NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE system.settings_history
|
||||||
|
(
|
||||||
|
LIKE system.settings
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER ip_list_history_trigger
|
||||||
|
BEFORE INSERT OR UPDATE OR DELETE
|
||||||
|
ON system.settings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.history_trigger_function();
|
24
api/src/data/scripts/2025-03-08-08-15-feature-flags.sql
Normal file
24
api/src/data/scripts/2025-03-08-08-15-feature-flags.sql
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
CREATE SCHEMA IF NOT EXISTS system;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS system.feature_flags
|
||||||
|
(
|
||||||
|
Id SERIAL PRIMARY KEY,
|
||||||
|
Key TEXT NOT NULL,
|
||||||
|
Value BOOLEAN NOT NULL,
|
||||||
|
-- for history
|
||||||
|
Deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
EditorId INT NULL REFERENCES administration.users (Id),
|
||||||
|
CreatedUtc timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
UpdatedUtc timestamptz NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE system.feature_flags_history
|
||||||
|
(
|
||||||
|
LIKE system.feature_flags
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER ip_list_history_trigger
|
||||||
|
BEFORE INSERT OR UPDATE OR DELETE
|
||||||
|
ON system.feature_flags
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.history_trigger_function();
|
25
api/src/data/scripts/2025-03-08-08-15-user-settings.sql
Normal file
25
api/src/data/scripts/2025-03-08-08-15-user-settings.sql
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
CREATE SCHEMA IF NOT EXISTS public;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.user_settings
|
||||||
|
(
|
||||||
|
Id SERIAL PRIMARY KEY,
|
||||||
|
Key TEXT NOT NULL,
|
||||||
|
Value TEXT NOT NULL,
|
||||||
|
UserId INT NOT NULL REFERENCES administration.users (Id) ON DELETE CASCADE,
|
||||||
|
-- for history
|
||||||
|
Deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
EditorId INT NULL REFERENCES administration.users (Id),
|
||||||
|
CreatedUtc timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
UpdatedUtc timestamptz NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE public.user_settings_history
|
||||||
|
(
|
||||||
|
LIKE public.user_settings
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER ip_list_history_trigger
|
||||||
|
BEFORE INSERT OR UPDATE OR DELETE
|
||||||
|
ON public.user_settings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.history_trigger_function();
|
40
api/src/data/seeder/feature_flags_seeder.py
Normal file
40
api/src/data/seeder/feature_flags_seeder.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from core.configuration.feature_flags import FeatureFlags
|
||||||
|
from core.configuration.feature_flags_enum import FeatureFlagsEnum
|
||||||
|
from core.logger import DBLogger
|
||||||
|
from data.abc.data_seeder_abc import DataSeederABC
|
||||||
|
from data.schemas.system.feature_flag import FeatureFlag
|
||||||
|
from data.schemas.system.feature_flag_dao import featureFlagDao
|
||||||
|
|
||||||
|
logger = DBLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlagsSeeder(DataSeederABC):
|
||||||
|
def __init__(self):
|
||||||
|
DataSeederABC.__init__(self)
|
||||||
|
|
||||||
|
async def seed(self):
|
||||||
|
logger.info("Seeding feature flags")
|
||||||
|
feature_flags = await featureFlagDao.get_all()
|
||||||
|
feature_flag_keys = [x.key for x in feature_flags]
|
||||||
|
|
||||||
|
possible_feature_flags = {
|
||||||
|
x.value: FeatureFlags.get_default(x) for x in FeatureFlagsEnum
|
||||||
|
}
|
||||||
|
|
||||||
|
to_create = [
|
||||||
|
FeatureFlag(0, x, possible_feature_flags[x])
|
||||||
|
for x in possible_feature_flags.keys()
|
||||||
|
if x not in feature_flag_keys
|
||||||
|
]
|
||||||
|
if len(to_create) > 0:
|
||||||
|
await featureFlagDao.create_many(to_create)
|
||||||
|
to_create_dicts = {x.key: x.value for x in to_create}
|
||||||
|
logger.debug(f"Created feature flags: {to_create_dicts}")
|
||||||
|
|
||||||
|
to_delete = [
|
||||||
|
x for x in feature_flags if x.key not in possible_feature_flags.keys()
|
||||||
|
]
|
||||||
|
if len(to_delete) > 0:
|
||||||
|
await featureFlagDao.delete_many(to_delete, hard_delete=True)
|
||||||
|
to_delete_dicts = {x.key: x.value for x in to_delete}
|
||||||
|
logger.debug(f"Deleted feature flags: {to_delete_dicts}")
|
25
api/src/data/seeder/settings_seeder.py
Normal file
25
api/src/data/seeder/settings_seeder.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from core.logger import DBLogger
|
||||||
|
from data.abc.data_seeder_abc import DataSeederABC
|
||||||
|
from data.schemas.system.setting import Setting
|
||||||
|
from data.schemas.system.setting_dao import settingsDao
|
||||||
|
|
||||||
|
logger = DBLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsSeeder(DataSeederABC):
|
||||||
|
def __init__(self):
|
||||||
|
DataSeederABC.__init__(self)
|
||||||
|
|
||||||
|
async def seed(self):
|
||||||
|
await self._seed_if_not_exists("default_language", "de")
|
||||||
|
await self._seed_if_not_exists("show_terms", True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _seed_if_not_exists(key: str, value: Any):
|
||||||
|
existing = await settingsDao.find_by_key(key)
|
||||||
|
if existing is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
await settingsDao.create(Setting(0, key, str(value)))
|
@ -1,10 +1,9 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import eventlet
|
import uvicorn
|
||||||
from eventlet import wsgi
|
|
||||||
|
|
||||||
from api.api import app
|
from api.api import API
|
||||||
from core.environment import Environment
|
from core.environment import Environment
|
||||||
from core.logger import Logger
|
from core.logger import Logger
|
||||||
from startup import Startup
|
from startup import Startup
|
||||||
@ -18,15 +17,13 @@ def main():
|
|||||||
|
|
||||||
asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy())
|
asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy())
|
||||||
|
|
||||||
loop = asyncio.new_event_loop()
|
Startup.configure()
|
||||||
loop.run_until_complete(Startup.configure())
|
uvicorn.run(
|
||||||
loop.close()
|
API.app,
|
||||||
|
host="0.0.0.0",
|
||||||
port = Environment.get("PORT", int, 5000)
|
port=Environment.get("PORT", int, 5000),
|
||||||
logger.info(f"Start API on port: {port}")
|
log_config=None,
|
||||||
if Environment.get_environment() == "development":
|
)
|
||||||
logger.info(f"Playground: http://localhost:{port}/ui/playground")
|
|
||||||
wsgi.server(eventlet.listen(("0.0.0.0", port)), app, log_output=False)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
@ -1,117 +1,188 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import eventlet
|
import requests
|
||||||
from eventlet import wsgi
|
import uvicorn
|
||||||
from flask import Flask, request, Response, redirect, render_template
|
from starlette.applications import Starlette
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import RedirectResponse
|
||||||
|
from starlette.routing import Route, Mount
|
||||||
|
from starlette.staticfiles import StaticFiles
|
||||||
|
from starlette.templating import Jinja2Templates
|
||||||
|
|
||||||
from core.database.database import Database
|
|
||||||
from core.environment import Environment
|
from core.environment import Environment
|
||||||
from core.logger import Logger
|
from core.logger import Logger
|
||||||
from data.schemas.public.short_url import ShortUrl
|
|
||||||
from data.schemas.public.short_url_dao import shortUrlDao
|
|
||||||
from data.schemas.public.short_url_visit import ShortUrlVisit
|
|
||||||
from data.schemas.public.short_url_visit_dao import shortUrlVisitDao
|
|
||||||
|
|
||||||
logger = Logger(__name__)
|
logger = Logger(__name__)
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
class Redirector(Flask):
|
async def index(request: Request):
|
||||||
|
return templates.TemplateResponse("404.html", {"request": request}, status_code=404)
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
Flask.__init__(self, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
app = Redirector(__name__)
|
async def handle_request(request: Request):
|
||||||
|
path = request.path_params["path"]
|
||||||
|
short_url = _find_short_url_by_path(path)
|
||||||
@app.route("/")
|
|
||||||
def index():
|
|
||||||
return render_template("404.html"), 404
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/<path:path>")
|
|
||||||
async def _handle_request(path: str):
|
|
||||||
short_url = await _find_short_url_by_url(path)
|
|
||||||
if short_url is None:
|
if short_url is None:
|
||||||
return render_template("404.html"), 404
|
return templates.TemplateResponse(
|
||||||
|
"404.html", {"request": request}, status_code=404
|
||||||
|
)
|
||||||
|
|
||||||
domains = Environment.get("DOMAINS", list[str], [])
|
domains = Environment.get("DOMAINS", list[str], [])
|
||||||
domain = await short_url.domain
|
domain = short_url["domain"]
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Domain: {domain.name if domain is not None else None}, request.host: {request.host}"
|
f"Domain: {domain["name"] if domain is not None else None}, request.host: {request.headers['host']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
host = request.host
|
host = request.headers["host"]
|
||||||
if ":" in host:
|
if ":" in host:
|
||||||
host = host.split(":")[0]
|
host = host.split(":")[0]
|
||||||
|
|
||||||
domain_strict_mode = Environment.get("DOMAIN_STRICT_MODE", bool, False)
|
domain_strict_mode = Environment.get("DOMAIN_STRICT_MODE", bool, False)
|
||||||
if domain is not None and (
|
if domain is not None and (
|
||||||
domain.name not in domains
|
domain["name"] not in domains
|
||||||
or (domain_strict_mode and not host.endswith(domain.name))
|
or (domain_strict_mode and not host.endswith(domain["name"]))
|
||||||
):
|
):
|
||||||
return render_template("404.html"), 404
|
return templates.TemplateResponse(
|
||||||
|
"404.html", {"request": request}, status_code=404
|
||||||
|
)
|
||||||
|
|
||||||
user_agent = request.headers.get("User-Agent", "").lower()
|
user_agent = request.headers.get("User-Agent", "").lower()
|
||||||
|
|
||||||
if "wheregoes" in user_agent or "someothertool" in user_agent:
|
if "wheregoes" in user_agent or "someothertool" in user_agent:
|
||||||
return await _handle_short_url(path, short_url)
|
return await _handle_short_url(request, short_url)
|
||||||
|
|
||||||
if short_url.loading_screen:
|
if short_url["loadingScreen"]:
|
||||||
await _track_visit(short_url)
|
await _track_visit(request, short_url)
|
||||||
|
|
||||||
return render_template(
|
return templates.TemplateResponse(
|
||||||
"redirect.html",
|
"redirect.html",
|
||||||
key=short_url.short_url,
|
{
|
||||||
target_url=_get_redirect_url(short_url.target_url),
|
"request": request,
|
||||||
|
"key": short_url["shortUrl"],
|
||||||
|
"target_url": _get_redirect_url(short_url["targetUrl"]),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return await _handle_short_url(path, short_url)
|
return await _handle_short_url(request, short_url)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_short_url(path: str, short_url: ShortUrl):
|
def _find_short_url_by_path(path: str) -> Optional[dict]:
|
||||||
if path.startswith("api/"):
|
api_url = Environment.get("API_URL", str)
|
||||||
path = path.replace("api/", "")
|
if api_url is None:
|
||||||
|
raise Exception("API_URL is not set")
|
||||||
|
|
||||||
await _track_visit(short_url)
|
api_key = Environment.get("API_KEY", str)
|
||||||
|
if api_key is None:
|
||||||
|
raise Exception("API_KEY is not set")
|
||||||
|
|
||||||
return _do_redirect(short_url.target_url)
|
request = requests.post(
|
||||||
|
f"{api_url}/graphql",
|
||||||
|
json={
|
||||||
|
"query": f"""
|
||||||
|
query getShortUrlByPath($path: String!) {{
|
||||||
|
shortUrls(filter: {{ shortUrl: {{ equal: $path }}, deleted: {{ equal: false }} }}) {{
|
||||||
|
nodes {{
|
||||||
|
id
|
||||||
|
shortUrl
|
||||||
|
targetUrl
|
||||||
|
description
|
||||||
|
group {{
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}}
|
||||||
|
domain {{
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}}
|
||||||
|
loadingScreen
|
||||||
|
deleted
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
""",
|
||||||
|
"variables": {"path": path},
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"API-Key {api_key}"},
|
||||||
|
)
|
||||||
|
data = request.json()["data"]["shortUrls"]["nodes"]
|
||||||
|
if len(data) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return data[0]
|
||||||
|
|
||||||
|
|
||||||
async def _track_visit(short_url: ShortUrl):
|
async def _handle_short_url(request: Request, short_url: dict):
|
||||||
|
await _track_visit(request, short_url)
|
||||||
|
|
||||||
|
return RedirectResponse(_get_redirect_url(short_url["targetUrl"]))
|
||||||
|
|
||||||
|
|
||||||
|
async def _track_visit(r: Request, short_url: dict):
|
||||||
|
api_url = Environment.get("API_URL", str)
|
||||||
|
if api_url is None:
|
||||||
|
raise Exception("API_URL is not set")
|
||||||
|
|
||||||
|
api_key = Environment.get("API_KEY", str)
|
||||||
|
if api_key is None:
|
||||||
|
raise Exception("API_KEY is not set")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await shortUrlVisitDao.create(
|
request = requests.post(
|
||||||
ShortUrlVisit(0, short_url.id, request.headers.get("User-Agent"))
|
f"{api_url}/graphql",
|
||||||
|
json={
|
||||||
|
"query": f"""
|
||||||
|
mutation trackShortUrlVisit($id: ID!, $agent: String) {{
|
||||||
|
shortUrl {{
|
||||||
|
trackVisit(id: $id, agent: $agent)
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
""",
|
||||||
|
"variables": {
|
||||||
|
"id": short_url["id"],
|
||||||
|
"agent": r.headers.get("User-Agent"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"API-Key {api_key}"},
|
||||||
)
|
)
|
||||||
|
if request.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to track visit for short url {short_url["shortUrl"]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
data = request.json()
|
||||||
|
if "errors" in data:
|
||||||
|
raise Exception(data["errors"])
|
||||||
|
else:
|
||||||
|
logger.debug(f"Tracked visit for short url {short_url["shortUrl"]}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update short url {short_url.short_url} with error", e)
|
logger.error(
|
||||||
|
f"Failed to update short url {short_url["shortUrl"]} with error", e
|
||||||
|
)
|
||||||
async def _find_short_url_by_url(url: str) -> ShortUrl:
|
|
||||||
return await shortUrlDao.find_single_by({ShortUrl.short_url: url})
|
|
||||||
|
|
||||||
|
|
||||||
def _get_redirect_url(url: str) -> str:
|
def _get_redirect_url(url: str) -> str:
|
||||||
# todo: multiple protocols like ts3://
|
|
||||||
if not url.startswith("http://") and not url.startswith("https://"):
|
if not url.startswith("http://") and not url.startswith("https://"):
|
||||||
url = f"http://{url}"
|
url = f"http://{url}"
|
||||||
|
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
def _do_redirect(url: str) -> Response:
|
|
||||||
return redirect(_get_redirect_url(url))
|
|
||||||
|
|
||||||
|
|
||||||
async def configure():
|
async def configure():
|
||||||
Logger.set_level(Environment.get("LOG_LEVEL", str, "info"))
|
Logger.set_level(Environment.get("LOG_LEVEL", str, "info"))
|
||||||
Environment.set_environment(Environment.get("ENVIRONMENT", str, "production"))
|
Environment.set_environment(Environment.get("ENVIRONMENT", str, "production"))
|
||||||
logger.info(f"Environment: {Environment.get_environment()}")
|
logger.info(f"Environment: {Environment.get_environment()}")
|
||||||
|
|
||||||
app.debug = Environment.get_environment() == "development"
|
|
||||||
|
|
||||||
await Database.startup_db()
|
routes = [
|
||||||
|
Route("/", endpoint=index),
|
||||||
|
Mount("/static", StaticFiles(directory="static"), name="static"),
|
||||||
|
Route("/{path:path}", endpoint=handle_request),
|
||||||
|
]
|
||||||
|
|
||||||
|
app = Starlette(routes=routes, on_startup=[configure])
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@ -120,26 +191,13 @@ def main():
|
|||||||
|
|
||||||
asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy())
|
asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy())
|
||||||
|
|
||||||
loop = asyncio.new_event_loop()
|
uvicorn.run(
|
||||||
loop.run_until_complete(configure())
|
app,
|
||||||
loop.close()
|
host="0.0.0.0",
|
||||||
|
port=Environment.get("PORT", int, 5001),
|
||||||
port = Environment.get("PORT", int, 5001)
|
log_config=None,
|
||||||
logger.info(f"Start API on port: {port}")
|
)
|
||||||
if Environment.get_environment() == "development":
|
|
||||||
logger.info(f"Playground: http://localhost:{port}/")
|
|
||||||
wsgi.server(eventlet.listen(("0.0.0.0", port)), app, log_output=False)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
# ((
|
|
||||||
# ( )
|
|
||||||
# ; / ,
|
|
||||||
# / \/
|
|
||||||
# / |
|
|
||||||
# / ~/
|
|
||||||
# / ) ) ~ edraft
|
|
||||||
# ___// | /
|
|
||||||
# --' \_~-,
|
|
||||||
|
@ -7,6 +7,9 @@ class Permissions(Enum):
|
|||||||
"""
|
"""
|
||||||
Administration
|
Administration
|
||||||
"""
|
"""
|
||||||
|
# administrator
|
||||||
|
administrator = "administrator"
|
||||||
|
|
||||||
# api keys
|
# api keys
|
||||||
api_keys = "api_keys"
|
api_keys = "api_keys"
|
||||||
api_keys_create = "api_keys.create"
|
api_keys_create = "api_keys.create"
|
||||||
@ -19,6 +22,10 @@ class Permissions(Enum):
|
|||||||
users_update = "users.update"
|
users_update = "users.update"
|
||||||
users_delete = "users.delete"
|
users_delete = "users.delete"
|
||||||
|
|
||||||
|
# settings
|
||||||
|
settings = "settings"
|
||||||
|
settings_update = "settings.update"
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Permissions
|
Permissions
|
||||||
"""
|
"""
|
||||||
|
@ -1,17 +1,31 @@
|
|||||||
from flask_cors import CORS
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from api.api import app
|
from ariadne.asgi import GraphQL
|
||||||
|
from ariadne.asgi.handlers import GraphQLTransportWSHandler
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.middleware import Middleware
|
||||||
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
|
from starlette.routing import WebSocketRoute
|
||||||
|
|
||||||
|
from api.api import API
|
||||||
from api.auth.keycloak_client import Keycloak
|
from api.auth.keycloak_client import Keycloak
|
||||||
|
from api.broadcast import broadcast
|
||||||
|
from api.middleware.logging import LoggingMiddleware
|
||||||
|
from api.middleware.request import RequestMiddleware
|
||||||
|
from api.middleware.websocket import AuthenticatedGraphQLTransportWSHandler
|
||||||
|
from api.route import Route
|
||||||
|
from api_graphql.service.schema import schema
|
||||||
from core.database.database import Database
|
from core.database.database import Database
|
||||||
from core.database.database_settings import DatabaseSettings
|
from core.database.database_settings import DatabaseSettings
|
||||||
from core.database.db_context import DBContext
|
from core.database.db_context import DBContext
|
||||||
from core.environment import Environment
|
from core.environment import Environment
|
||||||
from core.logger import Logger
|
from core.logger import Logger
|
||||||
from data.seeder.api_key_seeder import ApiKeySeeder
|
from data.seeder.api_key_seeder import ApiKeySeeder
|
||||||
|
from data.seeder.feature_flags_seeder import FeatureFlagsSeeder
|
||||||
from data.seeder.file_hash_seeder import FileHashSeeder
|
from data.seeder.file_hash_seeder import FileHashSeeder
|
||||||
from data.seeder.permission_seeder import PermissionSeeder
|
from data.seeder.permission_seeder import PermissionSeeder
|
||||||
from data.seeder.role_seeder import RoleSeeder
|
from data.seeder.role_seeder import RoleSeeder
|
||||||
from data.seeder.short_url_seeder import ShortUrlSeeder
|
from data.seeder.settings_seeder import SettingsSeeder
|
||||||
from data.service.migration_service import MigrationService
|
from data.service.migration_service import MigrationService
|
||||||
from service.file_service import FileService
|
from service.file_service import FileService
|
||||||
|
|
||||||
@ -19,15 +33,43 @@ logger = Logger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class Startup:
|
class Startup:
|
||||||
|
@classmethod
|
||||||
|
def _get_db_settings(cls):
|
||||||
|
host = Environment.get("DB_HOST", str)
|
||||||
|
port = Environment.get("DB_PORT", int)
|
||||||
|
user = Environment.get("DB_USER", str)
|
||||||
|
password = Environment.get("DB_PASSWORD", str)
|
||||||
|
database = Environment.get("DB_DATABASE", str)
|
||||||
|
|
||||||
|
if None in [host, port, user, password, database]:
|
||||||
|
logger.fatal(
|
||||||
|
"DB settings are not set correctly",
|
||||||
|
EnvironmentError("DB settings are not set correctly"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return DatabaseSettings(
|
||||||
|
host=host, port=port, user=user, password=password, database=database
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _startup_db(cls):
|
||||||
|
logger.info("Init DB")
|
||||||
|
db = DBContext()
|
||||||
|
|
||||||
|
await db.connect(cls._get_db_settings())
|
||||||
|
Database.init(db)
|
||||||
|
migrations = MigrationService(db)
|
||||||
|
await migrations.migrate()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _seed_data():
|
async def _seed_data():
|
||||||
seeders = [
|
seeders = [
|
||||||
|
SettingsSeeder,
|
||||||
|
FeatureFlagsSeeder,
|
||||||
PermissionSeeder,
|
PermissionSeeder,
|
||||||
RoleSeeder,
|
RoleSeeder,
|
||||||
ApiKeySeeder,
|
ApiKeySeeder,
|
||||||
FileHashSeeder,
|
FileHashSeeder,
|
||||||
ShortUrlSeeder,
|
|
||||||
]
|
]
|
||||||
for seeder in [x() for x in seeders]:
|
for seeder in [x() for x in seeders]:
|
||||||
await seeder.seed()
|
await seeder.seed()
|
||||||
@ -38,22 +80,68 @@ class Startup:
|
|||||||
Keycloak.init()
|
Keycloak.init()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def configure(cls):
|
async def _startup_broadcast(cls):
|
||||||
Logger.set_level(Environment.get("LOG_LEVEL", str, "info"))
|
logger.info("Init Broadcast")
|
||||||
Environment.set_environment(Environment.get("ENVIRONMENT", str, "production"))
|
await broadcast.connect()
|
||||||
logger.info(f"Environment: {Environment.get_environment()}")
|
|
||||||
|
|
||||||
app.debug = Environment.get_environment() == "development"
|
@classmethod
|
||||||
|
async def configure_api(cls):
|
||||||
await Database.startup_db()
|
await cls._startup_db()
|
||||||
await FileService.clean_files()
|
await FileService.clean_files()
|
||||||
|
|
||||||
await cls._seed_data()
|
await cls._seed_data()
|
||||||
cls._startup_keycloak()
|
cls._startup_keycloak()
|
||||||
|
await cls._startup_broadcast()
|
||||||
|
|
||||||
client_urls = Environment.get("CLIENT_URLS", str)
|
@staticmethod
|
||||||
if client_urls is None:
|
@asynccontextmanager
|
||||||
raise EnvironmentError("CLIENT_URLS not set")
|
async def api_lifespan(app: Starlette):
|
||||||
|
await Startup.configure_api()
|
||||||
|
|
||||||
origins = client_urls.split(",")
|
port = Environment.get("PORT", int, 5000)
|
||||||
CORS(app, support_credentials=True, resources={r"/api/*": {"origins": origins}})
|
logger.info(f"Start API server on port: {port}")
|
||||||
|
if Environment.get_environment() == "development":
|
||||||
|
logger.info(f"Playground: http://localhost:{port}/ui/playground")
|
||||||
|
|
||||||
|
app.debug = Environment.get_environment() == "development"
|
||||||
|
yield
|
||||||
|
logger.info("Shutdown API")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def init_api(cls):
|
||||||
|
logger.info("Init API")
|
||||||
|
API.import_routes()
|
||||||
|
API.create(
|
||||||
|
Starlette(
|
||||||
|
lifespan=cls.api_lifespan,
|
||||||
|
routes=[
|
||||||
|
*Route.registered_routes,
|
||||||
|
WebSocketRoute(
|
||||||
|
"/graphql",
|
||||||
|
endpoint=GraphQL(
|
||||||
|
schema,
|
||||||
|
websocket_handler=AuthenticatedGraphQLTransportWSHandler(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
middleware=[
|
||||||
|
Middleware(RequestMiddleware),
|
||||||
|
Middleware(LoggingMiddleware),
|
||||||
|
Middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=API.get_allowed_origins(),
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
exception_handlers={Exception: API.handle_exception},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def configure(cls):
|
||||||
|
Logger.set_level(Environment.get("LOG_LEVEL", str, "info"))
|
||||||
|
Environment.set_environment(Environment.get("ENVIRONMENT", str, "production"))
|
||||||
|
logger.info(f"Environment: {Environment.get_environment()}")
|
||||||
|
|
||||||
|
cls.init_api()
|
||||||
|
@ -8,17 +8,17 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="w-full h-full flex flex-col justify-center items-center">
|
<div class="w-full h-full flex flex-col justify-center items-center">
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center w-full h-full">
|
||||||
<div class="relative w-screen h-screen bg-cover bg-center"
|
<div class="relative w-full h-full bg-cover bg-center"
|
||||||
style="background-image: url('/static/custom/background.jpg')"></div>
|
style="background-image: url('/static/custom/background.jpg')"></div>
|
||||||
|
|
||||||
<div class="absolute w-1/3 h-2/5 rounded-xl p-5 flex flex-col gap-5 justify-center items-center">
|
<div class="absolute w-11/12 sm:w-2/3 md:w-1/2 lg:w-1/3 h-2/5 rounded-xl p-5 flex flex-col gap-5 justify-center items-center">
|
||||||
<div class="absolute inset-0 bg-black opacity-70 rounded-xl"></div>
|
<div class="absolute inset-0 bg-black opacity-70 rounded-xl"></div>
|
||||||
<div class="relative logo flex justify-center items-center">
|
<div class="relative logo flex justify-center items-center">
|
||||||
<img class="h-48" src="/static/custom/logo.png" alt="logo">
|
<img class="h-24 sm:h-32 md:h-48" src="/static/custom/logo.png" alt="logo">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="relative text-3xl text-white">Redirecting...</h1>
|
<h1 class="relative text-xl sm:text-2xl md:text-3xl text-white">Redirecting...</h1>
|
||||||
<p class="relative text-white">You will be redirected in <span id="countdown">5</span> seconds.</p>
|
<p class="relative text-white">You will be redirected in <span id="countdown">5</span> seconds.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
7
web/.gitignore
vendored
7
web/.gitignore
vendored
@ -1 +1,6 @@
|
|||||||
config.*.json
|
config.*.json
|
||||||
|
|
||||||
|
dist/
|
||||||
|
.angular/
|
||||||
|
node_modules/
|
||||||
|
coverage/
|
26
web/ngx-translate-lint.json
Executable file
26
web/ngx-translate-lint.json
Executable file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"rules": {
|
||||||
|
"keysOnViews": "error",
|
||||||
|
"zombieKeys": "error",
|
||||||
|
"misprintKeys": "disable",
|
||||||
|
"deepSearch": "enable",
|
||||||
|
"emptyKeys": "warning",
|
||||||
|
"maxWarning": "0",
|
||||||
|
"misprintCoefficient": "0.9",
|
||||||
|
"ignoredKeys": [
|
||||||
|
"permissions.*",
|
||||||
|
"permission_descriptions.*"
|
||||||
|
],
|
||||||
|
"ignoredMisprintKeys": [],
|
||||||
|
"customRegExpToFindKeys": [
|
||||||
|
"(?<=countHeaderTranslation=\")[A-Za-z0-9_.-]+(?=\")",
|
||||||
|
"(?<=translationKey:\\s*['\"])[A-Za-z0-9_.-]+(?=['\"])",
|
||||||
|
"(?<=(success|info|warn|error)\\(['\"])[A-Za-z0-9_.-]+(?=['\"]\\))",
|
||||||
|
"(?<=instant\\(['\"])[A-Za-z0-9_.-]+(?=['\"]\\))",
|
||||||
|
"(?<=\\.instant\\(['\"])[A-Za-z0-9_.-]+(?=['\"]\\))|(?<=\\?\\s*['\"])[A-Za-z0-9_.-]+(?=['\"]\\s*:\\s*['\"].*?\\|\\s*translate)|(?<=:\\s*['\"])[A-Za-z0-9_.-]+(?=['\"]\\s*\\|\\s*translate)\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fixZombiesKeys": false,
|
||||||
|
"project": "./src/app/**/*.{html,ts}",
|
||||||
|
"languages": "./src/assets/i18n/*.json"
|
||||||
|
}
|
209
web/package-lock.json
generated
209
web/package-lock.json
generated
@ -21,6 +21,7 @@
|
|||||||
"apollo-angular": "^7.2.1",
|
"apollo-angular": "^7.2.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.2.1",
|
"dompurify": "^3.2.1",
|
||||||
|
"graphql-ws": "^5.16.2",
|
||||||
"keycloak-angular": "^16.1.0",
|
"keycloak-angular": "^16.1.0",
|
||||||
"keycloak-js": "^26.0.5",
|
"keycloak-js": "^26.0.5",
|
||||||
"marked": "^12.0.2",
|
"marked": "^12.0.2",
|
||||||
@ -57,6 +58,7 @@
|
|||||||
"karma-coverage": "~2.2.0",
|
"karma-coverage": "~2.2.0",
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
|
"ngx-translate-lint": "^1.22.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"prettier-eslint": "^16.3.0",
|
"prettier-eslint": "^16.3.0",
|
||||||
@ -7377,6 +7379,13 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/conventional-cli": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/conventional-cli/-/conventional-cli-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-4EGXbt16iIOjTz7ocOInsHfjxL6NxdUNqnHv4XHxXfRc8ClZJcQB5SoxQhT7U2XmZ9y2O/PFFkT8hLwG3n+DJg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
|
||||||
@ -9711,6 +9720,13 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fs": {
|
||||||
|
"version": "0.0.1-security",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz",
|
||||||
|
"integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/fs-extra": {
|
"node_modules/fs-extra": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||||
@ -9996,6 +10012,18 @@
|
|||||||
"graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
|
"graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/graphql-ws": {
|
||||||
|
"version": "5.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.16.2.tgz",
|
||||||
|
"integrity": "sha512-E1uccsZxt/96jH/OwmLPuXMACILs76pKF2i3W861LpKBCYtGIyPQGtWLuBLkND4ox1KHns70e83PS4te50nvPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"graphql": ">=0.11 <=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hachure-fill": {
|
"node_modules/hachure-fill": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz",
|
||||||
@ -12835,6 +12863,141 @@
|
|||||||
"zone.js": "~0.14.0"
|
"zone.js": "~0.14.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ngx-translate-lint": {
|
||||||
|
"version": "1.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ngx-translate-lint/-/ngx-translate-lint-1.22.0.tgz",
|
||||||
|
"integrity": "sha512-7ECu8xs5OTWvJ6/9JC6CVhxooqRopGm6LO4BW9VhPQNFQJKuE13bipBxtW3jGz9ecyTVJuQ3hIVDG/8uSAkyig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chalk": "^2.4.2",
|
||||||
|
"commander": "^2.20.0",
|
||||||
|
"conventional-cli": "^1.2.0",
|
||||||
|
"dir-glob": "^3.0.1",
|
||||||
|
"fs": "0.0.1-security",
|
||||||
|
"glob": "^7.1.4",
|
||||||
|
"lodash": "^4.17.20",
|
||||||
|
"path": "^0.12.7",
|
||||||
|
"rxjs": "^6.5.4",
|
||||||
|
"string-similarity": "^4.0.1",
|
||||||
|
"typescript": "^4.1.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"ngx-translate-lint": "dist/bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ngx-translate-lint/node_modules/ansi-styles": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^1.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ngx-translate-lint/node_modules/chalk": {
|
||||||
|
"version": "2.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||||
|
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^3.2.1",
|
||||||
|
"escape-string-regexp": "^1.0.5",
|
||||||
|
"supports-color": "^5.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ngx-translate-lint/node_modules/color-convert": {
|
||||||
|
"version": "1.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||||
|
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "1.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ngx-translate-lint/node_modules/color-name": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/ngx-translate-lint/node_modules/escape-string-regexp": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ngx-translate-lint/node_modules/has-flag": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ngx-translate-lint/node_modules/rxjs": {
|
||||||
|
"version": "6.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
|
||||||
|
"integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^1.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"npm": ">=2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ngx-translate-lint/node_modules/supports-color": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ngx-translate-lint/node_modules/tslib": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/ngx-translate-lint/node_modules/typescript": {
|
||||||
|
"version": "4.9.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||||
|
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nice-napi": {
|
"node_modules/nice-napi": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz",
|
||||||
@ -13634,6 +13797,17 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path": {
|
||||||
|
"version": "0.12.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
|
||||||
|
"integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"process": "^0.11.1",
|
||||||
|
"util": "^0.10.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-data-parser": {
|
"node_modules/path-data-parser": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz",
|
||||||
@ -14467,6 +14641,16 @@
|
|||||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process": {
|
||||||
|
"version": "0.11.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||||
|
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/process-nextick-args": {
|
"node_modules/process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
@ -16087,6 +16271,14 @@
|
|||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-similarity": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-similarity/-/string-similarity-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==",
|
||||||
|
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||||
@ -17019,6 +17211,16 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util": {
|
||||||
|
"version": "0.10.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
|
||||||
|
"integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
@ -17026,6 +17228,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/util/node_modules/inherits": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/utils-merge": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
@ -9,6 +9,8 @@
|
|||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"test:ci": "ng test --browsers=ChromeHeadlessCustom --watch=false --code-coverage",
|
"test:ci": "ng test --browsers=ChromeHeadlessCustom --watch=false --code-coverage",
|
||||||
"lint": "ng lint",
|
"lint": "ng lint",
|
||||||
|
"lint:fix": "ng lint --fix",
|
||||||
|
"lint:translations": "ngx-translate-lint -c ngx-translate-lint.json",
|
||||||
"prettiefy": "prettier --write \"src/**/*.ts\""
|
"prettiefy": "prettier --write \"src/**/*.ts\""
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
@ -26,6 +28,7 @@
|
|||||||
"apollo-angular": "^7.2.1",
|
"apollo-angular": "^7.2.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.2.1",
|
"dompurify": "^3.2.1",
|
||||||
|
"graphql-ws": "^5.16.2",
|
||||||
"keycloak-angular": "^16.1.0",
|
"keycloak-angular": "^16.1.0",
|
||||||
"keycloak-js": "^26.0.5",
|
"keycloak-js": "^26.0.5",
|
||||||
"marked": "^12.0.2",
|
"marked": "^12.0.2",
|
||||||
@ -62,6 +65,7 @@
|
|||||||
"karma-coverage": "~2.2.0",
|
"karma-coverage": "~2.2.0",
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
|
"ngx-translate-lint": "^1.22.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"prettier-eslint": "^16.3.0",
|
"prettier-eslint": "^16.3.0",
|
||||||
|
@ -3,6 +3,7 @@ import { RouterModule, Routes } from '@angular/router';
|
|||||||
import { NotFoundComponent } from 'src/app/components/error/not-found/not-found.component';
|
import { NotFoundComponent } from 'src/app/components/error/not-found/not-found.component';
|
||||||
import { AuthGuard } from 'src/app/core/guard/auth.guard';
|
import { AuthGuard } from 'src/app/core/guard/auth.guard';
|
||||||
import { HomeComponent } from 'src/app/components/home/home.component';
|
import { HomeComponent } from 'src/app/components/home/home.component';
|
||||||
|
import { ServerUnavailableComponent } from 'src/app/components/error/server-unavailable/server-unavailable.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{
|
{
|
||||||
@ -15,8 +16,12 @@ const routes: Routes = [
|
|||||||
import('./modules/admin/admin.module').then(m => m.AdminModule),
|
import('./modules/admin/admin.module').then(m => m.AdminModule),
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
},
|
},
|
||||||
{ path: '404', component: NotFoundComponent },
|
{ path: 'error/404', component: NotFoundComponent },
|
||||||
{ path: '**', redirectTo: '/404', pathMatch: 'full' },
|
{ path: 'error/unavailable', component: ServerUnavailableComponent },
|
||||||
|
{
|
||||||
|
path: '**',
|
||||||
|
redirectTo: 'error/404',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@ -4,6 +4,7 @@ import { BrowserModule } from '@angular/platform-browser';
|
|||||||
import { AppRoutingModule } from './app-routing.module';
|
import { AppRoutingModule } from './app-routing.module';
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { KeycloakService } from 'keycloak-angular';
|
import { KeycloakService } from 'keycloak-angular';
|
||||||
|
import { HomeComponent } from './components/home/home.component';
|
||||||
import { initializeKeycloak } from './core/init-keycloak';
|
import { initializeKeycloak } from './core/init-keycloak';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { environment } from '../environments/environment';
|
import { environment } from '../environments/environment';
|
||||||
@ -20,8 +21,8 @@ import { DialogService } from 'primeng/dynamicdialog';
|
|||||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||||
import { SidebarComponent } from './components/sidebar/sidebar.component';
|
import { SidebarComponent } from './components/sidebar/sidebar.component';
|
||||||
import { ErrorHandlingService } from 'src/app/service/error-handling.service';
|
import { ErrorHandlingService } from 'src/app/service/error-handling.service';
|
||||||
import { HomeComponent } from './components/home/home.component';
|
import { ConfigService } from 'src/app/service/config.service';
|
||||||
import { SettingsService } from 'src/app/service/settings.service';
|
import { ServerUnavailableComponent } from 'src/app/components/error/server-unavailable/server-unavailable.component';
|
||||||
|
|
||||||
if (environment.production) {
|
if (environment.production) {
|
||||||
Logger.enableProductionMode();
|
Logger.enableProductionMode();
|
||||||
@ -35,13 +36,13 @@ export function HttpLoaderFactory(http: HttpClient) {
|
|||||||
|
|
||||||
export function appInitializerFactory(
|
export function appInitializerFactory(
|
||||||
keycloak: KeycloakService,
|
keycloak: KeycloakService,
|
||||||
settings: SettingsService
|
config: ConfigService
|
||||||
): () => Promise<void> {
|
): () => Promise<void> {
|
||||||
return (): Promise<void> =>
|
return (): Promise<void> =>
|
||||||
new Promise<void>((resolve, reject) => {
|
new Promise<void>((resolve, reject) => {
|
||||||
settings
|
config
|
||||||
.loadSettings()
|
.loadSettings()
|
||||||
.then(() => initializeKeycloak(keycloak, settings))
|
.then(() => initializeKeycloak(keycloak, config))
|
||||||
.then(() => resolve())
|
.then(() => resolve())
|
||||||
.catch(error => reject(error));
|
.catch(error => reject(error));
|
||||||
});
|
});
|
||||||
@ -50,12 +51,13 @@ export function appInitializerFactory(
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
AppComponent,
|
AppComponent,
|
||||||
|
HomeComponent,
|
||||||
FooterComponent,
|
FooterComponent,
|
||||||
HeaderComponent,
|
HeaderComponent,
|
||||||
NotFoundComponent,
|
NotFoundComponent,
|
||||||
|
ServerUnavailableComponent,
|
||||||
SpinnerComponent,
|
SpinnerComponent,
|
||||||
SidebarComponent,
|
SidebarComponent,
|
||||||
HomeComponent,
|
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
@ -86,7 +88,7 @@ export function appInitializerFactory(
|
|||||||
provide: APP_INITIALIZER,
|
provide: APP_INITIALIZER,
|
||||||
useFactory: appInitializerFactory,
|
useFactory: appInitializerFactory,
|
||||||
multi: true,
|
multi: true,
|
||||||
deps: [KeycloakService, SettingsService],
|
deps: [KeycloakService, ConfigService],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: ErrorHandler,
|
provide: ErrorHandler,
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
<div class="w-full h-full flex flex-col justify-center items-center">
|
<div class="w-full h-full flex flex-col justify-center items-center">
|
||||||
<div class="bg-2 padding-10 rounded-15">
|
<div class="bg2 flex p-10 rounded-xl text-center">
|
||||||
<h1 class="flex justify-center items-center">
|
<div class="flex flex-col gap-5">
|
||||||
{{ 'error.404' | translate }}
|
<h1 class="flex justify-center items-center">
|
||||||
</h1>
|
{{ 'error.404' | translate }}
|
||||||
<img src="/assets/not_found.gif" alt="" />
|
</h1>
|
||||||
|
<img src="/assets/not_found.gif" alt="" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
import { NotFoundComponent } from "src/app/components/error/not-found/not-found.component";
|
import { NotFoundComponent } from 'src/app/components/error/not-found/not-found.component';
|
||||||
import { TranslateModule } from "@ngx-translate/core";
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
|
||||||
describe("NotFoundComponent", () => {
|
describe('NotFoundComponent', () => {
|
||||||
let component: NotFoundComponent;
|
let component: NotFoundComponent;
|
||||||
let fixture: ComponentFixture<NotFoundComponent>;
|
let fixture: ComponentFixture<NotFoundComponent>;
|
||||||
|
|
||||||
@ -20,7 +20,7 @@ describe("NotFoundComponent", () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create", () => {
|
it('should create', () => {
|
||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { Component } from "@angular/core";
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-not-found",
|
selector: 'app-not-found',
|
||||||
templateUrl: "./not-found.component.html",
|
templateUrl: './not-found.component.html',
|
||||||
styleUrls: ["./not-found.component.scss"],
|
styleUrls: ['./not-found.component.scss'],
|
||||||
})
|
})
|
||||||
export class NotFoundComponent {}
|
export class NotFoundComponent {}
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
<div class="w-full h-full flex flex-col justify-center items-center">
|
||||||
|
<div class="bg2 flex p-10 rounded-xl text-center">
|
||||||
|
<div class="flex flex-col gap-5">
|
||||||
|
<h1 class="flex justify-center items-center">
|
||||||
|
{{ 'error.server_unavailable' | translate }}
|
||||||
|
</h1>
|
||||||
|
<p-button (onClick)="retryConnection()" class="btn btn-primary">
|
||||||
|
{{ 'error.retry' | translate }}
|
||||||
|
</p-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,4 @@
|
|||||||
|
h1 {
|
||||||
|
color: #a03033;
|
||||||
|
font-size: 3rem !important;
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { NotFoundComponent } from 'src/app/components/error/not-found/not-found.component';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
describe('NotFoundComponent', () => {
|
||||||
|
let component: NotFoundComponent;
|
||||||
|
let fixture: ComponentFixture<NotFoundComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [NotFoundComponent],
|
||||||
|
imports: [TranslateModule.forRoot()],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(NotFoundComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,15 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-server-unavailable',
|
||||||
|
templateUrl: './server-unavailable.component.html',
|
||||||
|
styleUrls: ['./server-unavailable.component.scss'],
|
||||||
|
})
|
||||||
|
export class ServerUnavailableComponent {
|
||||||
|
constructor(private router: Router) {}
|
||||||
|
|
||||||
|
async retryConnection() {
|
||||||
|
await this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
}
|
@ -1,23 +1,23 @@
|
|||||||
import { Component } from "@angular/core";
|
import { Component } from '@angular/core';
|
||||||
import { SettingsService } from "src/app/service/settings.service";
|
import { ConfigService } from 'src/app/service/config.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-footer",
|
selector: 'app-footer',
|
||||||
templateUrl: "./footer.component.html",
|
templateUrl: './footer.component.html',
|
||||||
styleUrls: ["./footer.component.scss"],
|
styleUrls: ['./footer.component.scss'],
|
||||||
})
|
})
|
||||||
export class FooterComponent {
|
export class FooterComponent {
|
||||||
constructor(private settings: SettingsService) {}
|
constructor(private config: ConfigService) {}
|
||||||
|
|
||||||
get termsUrl(): string {
|
get termsUrl(): string {
|
||||||
return this.settings.settings.termsUrl;
|
return this.config.settings.termsUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
get privacyUrl(): string {
|
get privacyUrl(): string {
|
||||||
return this.settings.settings.privacyURL;
|
return this.config.settings.privacyURL;
|
||||||
}
|
}
|
||||||
|
|
||||||
get imprintUrl(): string {
|
get imprintUrl(): string {
|
||||||
return this.settings.settings.imprintURL;
|
return this.config.settings.imprintURL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,8 @@ import { User } from 'src/app/model/auth/user';
|
|||||||
import { AuthService } from 'src/app/service/auth.service';
|
import { AuthService } from 'src/app/service/auth.service';
|
||||||
import { MenuElement } from 'src/app/model/view/menu-element';
|
import { MenuElement } from 'src/app/model/view/menu-element';
|
||||||
import { SidebarService } from 'src/app/service/sidebar.service';
|
import { SidebarService } from 'src/app/service/sidebar.service';
|
||||||
|
import { ConfigService } from 'src/app/service/config.service';
|
||||||
|
import { UserSettingsService } from 'src/app/service/user_settings.service';
|
||||||
import { SettingsService } from 'src/app/service/settings.service';
|
import { SettingsService } from 'src/app/service/settings.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -28,11 +30,13 @@ export class HeaderComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private translateService: TranslateService,
|
private translateService: TranslateService,
|
||||||
private config: PrimeNGConfig,
|
private ngConfig: PrimeNGConfig,
|
||||||
private guiService: GuiService,
|
private guiService: GuiService,
|
||||||
private auth: AuthService,
|
private auth: AuthService,
|
||||||
private sidebarService: SidebarService,
|
private sidebarService: SidebarService,
|
||||||
private settings: SettingsService
|
private config: ConfigService,
|
||||||
|
private settings: SettingsService,
|
||||||
|
private userSettings: UserSettingsService
|
||||||
) {
|
) {
|
||||||
this.guiService.isMobile$
|
this.guiService.isMobile$
|
||||||
.pipe(takeUntil(this.unsubscribe$))
|
.pipe(takeUntil(this.unsubscribe$))
|
||||||
@ -46,13 +50,18 @@ export class HeaderComponent implements OnInit, OnDestroy {
|
|||||||
this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(async user => {
|
this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(async user => {
|
||||||
this.user = user;
|
this.user = user;
|
||||||
await this.initMenuLists();
|
await this.initMenuLists();
|
||||||
|
if (user) {
|
||||||
|
await this.loadTheme();
|
||||||
|
await this.loadLang();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.themeList = this.settings.settings.themes.map(theme => {
|
this.themeList = this.config.settings.themes.map(theme => {
|
||||||
return {
|
return {
|
||||||
label: theme.label,
|
label: theme.label,
|
||||||
command: () => {
|
command: async () => {
|
||||||
this.guiService.theme$.next(theme.name);
|
this.guiService.theme$.next(theme.name);
|
||||||
|
await this.userSettings.set('theme', theme.name);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -60,7 +69,6 @@ export class HeaderComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
await this.initMenuLists();
|
await this.initMenuLists();
|
||||||
await this.loadLang();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
@ -73,24 +81,49 @@ export class HeaderComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initMenuLists() {
|
async initMenuLists() {
|
||||||
|
await this.initMenuList();
|
||||||
await this.initLangMenuList();
|
await this.initLangMenuList();
|
||||||
await this.initUserMenuList();
|
await this.initUserMenuList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async initMenuList() {
|
||||||
|
this.menu = [
|
||||||
|
{
|
||||||
|
label: 'common.news',
|
||||||
|
routerLink: ['/'],
|
||||||
|
icon: 'pi pi-home',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'header.menu.about',
|
||||||
|
routerLink: ['/about'],
|
||||||
|
icon: 'pi pi-info',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this.auth.user$.value) {
|
||||||
|
this.menu.push({
|
||||||
|
label: 'header.menu.admin',
|
||||||
|
routerLink: ['/admin'],
|
||||||
|
icon: 'pi pi-cog',
|
||||||
|
visible: await this.auth.isAdmin(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async initLangMenuList() {
|
async initLangMenuList() {
|
||||||
this.langList = [
|
this.langList = [
|
||||||
{
|
{
|
||||||
label: 'English',
|
label: 'English',
|
||||||
command: () => {
|
command: async () => {
|
||||||
this.translate('en');
|
this.translate('en');
|
||||||
this.setLang('en');
|
await this.setLang('en');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Deutsch',
|
label: 'Deutsch',
|
||||||
command: () => {
|
command: async () => {
|
||||||
this.translate('de');
|
this.translate('de');
|
||||||
this.setLang('de');
|
await this.setLang('de');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -108,10 +141,8 @@ export class HeaderComponent implements OnInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: this.translateService.instant('header.logout'),
|
label: this.translateService.instant('header.logout'),
|
||||||
command: () => {
|
command: async () => {
|
||||||
this.auth.logout().then(() => {
|
await this.auth.logout();
|
||||||
console.log('logout');
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
icon: 'pi pi-sign-out',
|
icon: 'pi pi-sign-out',
|
||||||
},
|
},
|
||||||
@ -122,18 +153,36 @@ export class HeaderComponent implements OnInit, OnDestroy {
|
|||||||
this.translateService.use(lang);
|
this.translateService.use(lang);
|
||||||
this.translateService
|
this.translateService
|
||||||
.get('primeng')
|
.get('primeng')
|
||||||
.subscribe(res => this.config.setTranslation(res));
|
.subscribe(res => this.ngConfig.setTranslation(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadTheme() {
|
||||||
|
const defaultTheme = (await this.settings.get('default_theme')) ?? 'maxlan';
|
||||||
|
const userTheme = await this.userSettings.get('theme');
|
||||||
|
const theme = userTheme ?? defaultTheme;
|
||||||
|
|
||||||
|
this.guiService.theme$.next(theme);
|
||||||
|
|
||||||
|
if (!userTheme) {
|
||||||
|
await this.userSettings.set('theme', theme);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadLang() {
|
async loadLang() {
|
||||||
const lang = 'en';
|
const defaultLang = (await this.settings.get('default_language')) ?? 'en';
|
||||||
this.setLang(lang);
|
const userLang = await this.userSettings.get('language');
|
||||||
|
const lang = userLang ?? defaultLang;
|
||||||
|
|
||||||
this.translate(lang);
|
this.translate(lang);
|
||||||
|
|
||||||
|
if (userLang) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.userSettings.set('language', lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLang(lang: string) {
|
async setLang(lang: string) {
|
||||||
// this.settings.setSetting(`lang`, lang);
|
await this.userSettings.set('language', lang);
|
||||||
console.log('setLang', lang);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSidebar() {
|
toggleSidebar() {
|
||||||
|
@ -1,14 +1,38 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
import { HomeComponent } from "./home.component";
|
import { HomeComponent } from './home.component';
|
||||||
|
import { SharedModule } from 'src/app/modules/shared/shared.module';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { AuthService } from 'src/app/service/auth.service';
|
||||||
|
import { KeycloakService } from 'keycloak-angular';
|
||||||
|
import { ErrorHandlingService } from 'src/app/service/error-handling.service';
|
||||||
|
import { ToastService } from 'src/app/service/toast.service';
|
||||||
|
import { ConfirmationService, MessageService } from 'primeng/api';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
describe("HomeComponent", () => {
|
describe('HomeComponent', () => {
|
||||||
let component: HomeComponent;
|
let component: HomeComponent;
|
||||||
let fixture: ComponentFixture<HomeComponent>;
|
let fixture: ComponentFixture<HomeComponent>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
declarations: [HomeComponent],
|
declarations: [HomeComponent],
|
||||||
|
imports: [SharedModule, TranslateModule.forRoot()],
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
KeycloakService,
|
||||||
|
ErrorHandlingService,
|
||||||
|
ToastService,
|
||||||
|
MessageService,
|
||||||
|
ConfirmationService,
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: {
|
||||||
|
snapshot: { params: of({}) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(HomeComponent);
|
fixture = TestBed.createComponent(HomeComponent);
|
||||||
@ -16,7 +40,7 @@ describe("HomeComponent", () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create", () => {
|
it('should create', () => {
|
||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { Component } from "@angular/core";
|
import { Component } from '@angular/core';
|
||||||
import { KeycloakService } from "keycloak-angular";
|
import { KeycloakService } from 'keycloak-angular';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-home",
|
selector: 'app-home',
|
||||||
templateUrl: "./home.component.html",
|
templateUrl: './home.component.html',
|
||||||
styleUrl: "./home.component.scss",
|
styleUrl: './home.component.scss',
|
||||||
})
|
})
|
||||||
export class HomeComponent {
|
export class HomeComponent {
|
||||||
constructor(private keycloak: KeycloakService) {
|
constructor(private keycloak: KeycloakService) {
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { Directive, inject } from "@angular/core";
|
import { Directive, inject } from '@angular/core';
|
||||||
import { PageDataService } from "src/app/core/base/page.data.service";
|
import { PageDataService } from 'src/app/core/base/page.data.service';
|
||||||
import { SpinnerService } from "src/app/service/spinner.service";
|
import { SpinnerService } from 'src/app/service/spinner.service';
|
||||||
import { FilterService } from "src/app/service/filter.service";
|
import { FilterService } from 'src/app/service/filter.service';
|
||||||
import { FormGroup } from "@angular/forms";
|
import { FormGroup } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from "@angular/router";
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
|
||||||
@Directive()
|
@Directive()
|
||||||
export abstract class FormPageBase<
|
export abstract class FormPageBase<
|
||||||
@ -27,7 +27,7 @@ export abstract class FormPageBase<
|
|||||||
protected dataService = inject(PageDataService) as S;
|
protected dataService = inject(PageDataService) as S;
|
||||||
|
|
||||||
protected constructor() {
|
protected constructor() {
|
||||||
const id = this.route.snapshot.params["id"];
|
const id = this.route.snapshot.params['id'];
|
||||||
this.validateRoute(id);
|
this.validateRoute(id);
|
||||||
|
|
||||||
this.buildForm();
|
this.buildForm();
|
||||||
@ -35,18 +35,18 @@ export abstract class FormPageBase<
|
|||||||
|
|
||||||
validateRoute(id: string | undefined) {
|
validateRoute(id: string | undefined) {
|
||||||
const url = this.router.url;
|
const url = this.router.url;
|
||||||
if (url.endsWith("create") && id !== undefined) {
|
if (url.endsWith('create') && id !== undefined) {
|
||||||
throw new Error("Route ends with create but id is defined");
|
throw new Error('Route ends with create but id is defined');
|
||||||
}
|
}
|
||||||
if (url.endsWith("edit") && (id === undefined || isNaN(+id))) {
|
if (url.endsWith('edit') && (id === undefined || isNaN(+id))) {
|
||||||
throw new Error("Route ends with edit but id is not a number");
|
throw new Error('Route ends with edit but id is not a number');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.nodeId = id ? +id : undefined;
|
this.nodeId = id ? +id : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
const backRoute = this.nodeId ? "../.." : "..";
|
const backRoute = this.nodeId ? '../..' : '..';
|
||||||
|
|
||||||
this.router.navigate([backRoute], { relativeTo: this.route }).then(() => {
|
this.router.navigate([backRoute], { relativeTo: this.route }).then(() => {
|
||||||
this.filterService.onLoad.emit();
|
this.filterService.onLoad.emit();
|
||||||
|
@ -1,21 +1,21 @@
|
|||||||
import { Directive, inject, OnDestroy } from "@angular/core";
|
import { Directive, inject, OnDestroy } from '@angular/core';
|
||||||
import { PageDataService } from "src/app/core/base/page.data.service";
|
import { PageDataService } from 'src/app/core/base/page.data.service';
|
||||||
import { Subject } from "rxjs";
|
import { Subject } from 'rxjs';
|
||||||
import { Logger } from "src/app/service/logger.service";
|
import { Logger } from 'src/app/service/logger.service';
|
||||||
import { QueryResult } from "src/app/model/entities/query-result";
|
import { QueryResult } from 'src/app/model/entities/query-result';
|
||||||
import { SpinnerService } from "src/app/service/spinner.service";
|
import { SpinnerService } from 'src/app/service/spinner.service';
|
||||||
import { FilterService } from "src/app/service/filter.service";
|
import { FilterService } from 'src/app/service/filter.service';
|
||||||
import { Filter } from "src/app/model/graphql/filter/filter.model";
|
import { Filter } from 'src/app/model/graphql/filter/filter.model';
|
||||||
import { Sort } from "src/app/model/graphql/filter/sort.model";
|
import { Sort } from 'src/app/model/graphql/filter/sort.model';
|
||||||
import { takeUntil } from "rxjs/operators";
|
import { takeUntil } from 'rxjs/operators';
|
||||||
import { PaginatorState } from "primeng/paginator";
|
import { PaginatorState } from 'primeng/paginator';
|
||||||
import { PageColumns } from "src/app/core/base/page.columns";
|
import { PageColumns } from 'src/app/core/base/page.columns';
|
||||||
import {
|
import {
|
||||||
TableColumn,
|
TableColumn,
|
||||||
TableRequireAnyPermissions,
|
TableRequireAnyPermissions,
|
||||||
} from "src/app/modules/shared/components/table/table.model";
|
} from 'src/app/modules/shared/components/table/table.model';
|
||||||
|
|
||||||
const logger = new Logger("PageBase");
|
const logger = new Logger('PageBase');
|
||||||
|
|
||||||
@Directive()
|
@Directive()
|
||||||
export abstract class PageBase<
|
export abstract class PageBase<
|
||||||
@ -96,13 +96,13 @@ export abstract class PageBase<
|
|||||||
}
|
}
|
||||||
|
|
||||||
columns: TableColumn<T>[] =
|
columns: TableColumn<T>[] =
|
||||||
"get" in this.columnsService ? this.columnsService.get() : [];
|
'get' in this.columnsService ? this.columnsService.get() : [];
|
||||||
|
|
||||||
protected unsubscribe$ = new Subject<void>();
|
protected unsubscribe$ = new Subject<void>();
|
||||||
|
|
||||||
protected constructor(
|
protected constructor(
|
||||||
useQueryParams = false,
|
useQueryParams = false,
|
||||||
permissions?: TableRequireAnyPermissions,
|
permissions?: TableRequireAnyPermissions
|
||||||
) {
|
) {
|
||||||
this.subscribeToFilterService();
|
this.subscribeToFilterService();
|
||||||
this.filterService.reset({
|
this.filterService.reset({
|
||||||
@ -110,10 +110,17 @@ export abstract class PageBase<
|
|||||||
withHideDeleted: true,
|
withHideDeleted: true,
|
||||||
});
|
});
|
||||||
this.requiredPermissions = permissions ?? {};
|
this.requiredPermissions = permissions ?? {};
|
||||||
|
|
||||||
|
this.dataService
|
||||||
|
.onChange()
|
||||||
|
.pipe(takeUntil(this.unsubscribe$))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.load(true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
logger.trace("Destroy component");
|
logger.trace('Destroy component');
|
||||||
this.unsubscribe$.next();
|
this.unsubscribe$.next();
|
||||||
this.unsubscribe$.complete();
|
this.unsubscribe$.complete();
|
||||||
}
|
}
|
||||||
@ -125,26 +132,26 @@ export abstract class PageBase<
|
|||||||
|
|
||||||
this.filterService.filter$
|
this.filterService.filter$
|
||||||
.pipe(takeUntil(this.unsubscribe$))
|
.pipe(takeUntil(this.unsubscribe$))
|
||||||
.subscribe((filter) => {
|
.subscribe(filter => {
|
||||||
this._filter = filter;
|
this._filter = filter;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.filterService.sort$
|
this.filterService.sort$
|
||||||
.pipe(takeUntil(this.unsubscribe$))
|
.pipe(takeUntil(this.unsubscribe$))
|
||||||
.subscribe((sort) => {
|
.subscribe(sort => {
|
||||||
this._sort = sort;
|
this._sort = sort;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.filterService.skip$
|
this.filterService.skip$
|
||||||
.pipe(takeUntil(this.unsubscribe$))
|
.pipe(takeUntil(this.unsubscribe$))
|
||||||
.subscribe((skip) => {
|
.subscribe(skip => {
|
||||||
this._skip = skip;
|
this._skip = skip;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.filterService.take$
|
this.filterService.take$
|
||||||
.pipe(takeUntil(this.unsubscribe$))
|
.pipe(takeUntil(this.unsubscribe$))
|
||||||
.subscribe((take) => {
|
.subscribe(take => {
|
||||||
if (take && Object.prototype.hasOwnProperty.call(take, "showAll")) {
|
if (take && Object.prototype.hasOwnProperty.call(take, 'showAll')) {
|
||||||
this._take = 0;
|
this._take = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -163,5 +170,5 @@ export abstract class PageBase<
|
|||||||
this.filterService.onLoad.emit();
|
this.filterService.onLoad.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract load(): void;
|
abstract load(silent?: boolean): void;
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user