feat: migrate part of the web API module to Flask-RESTX (#24577)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
85b0b8373b
commit
917ed8cf84
7 changed files with 204 additions and 36 deletions
|
|
@ -1,19 +1,20 @@
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
|
from flask_restx import Namespace
|
||||||
|
|
||||||
from libs.external_api import ExternalApi
|
from libs.external_api import ExternalApi
|
||||||
|
|
||||||
from .files import FileApi
|
|
||||||
from .remote_files import RemoteFileInfoApi, RemoteFileUploadApi
|
|
||||||
|
|
||||||
bp = Blueprint("web", __name__, url_prefix="/api")
|
bp = Blueprint("web", __name__, url_prefix="/api")
|
||||||
api = ExternalApi(bp)
|
|
||||||
|
|
||||||
# Files
|
api = ExternalApi(
|
||||||
api.add_resource(FileApi, "/files/upload")
|
bp,
|
||||||
|
version="1.0",
|
||||||
|
title="Web API",
|
||||||
|
description="Public APIs for web applications including file uploads, chat interactions, and app management",
|
||||||
|
doc="/docs", # Enable Swagger UI at /api/docs
|
||||||
|
)
|
||||||
|
|
||||||
# Remote files
|
# Create namespace
|
||||||
api.add_resource(RemoteFileInfoApi, "/remote-files/<path:url>")
|
web_ns = Namespace("web", description="Web application API operations", path="/")
|
||||||
api.add_resource(RemoteFileUploadApi, "/remote-files/upload")
|
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
app,
|
app,
|
||||||
|
|
@ -21,11 +22,15 @@ from . import (
|
||||||
completion,
|
completion,
|
||||||
conversation,
|
conversation,
|
||||||
feature,
|
feature,
|
||||||
|
files,
|
||||||
forgot_password,
|
forgot_password,
|
||||||
login,
|
login,
|
||||||
message,
|
message,
|
||||||
passport,
|
passport,
|
||||||
|
remote_files,
|
||||||
saved_message,
|
saved_message,
|
||||||
site,
|
site,
|
||||||
workflow,
|
workflow,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
api.add_namespace(web_ns)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
|
|
||||||
from controllers.web import api
|
from controllers.web import web_ns
|
||||||
from services.feature_service import FeatureService
|
from services.feature_service import FeatureService
|
||||||
|
|
||||||
|
|
||||||
|
@web_ns.route("/system-features")
|
||||||
class SystemFeatureApi(Resource):
|
class SystemFeatureApi(Resource):
|
||||||
|
@web_ns.doc("get_system_features")
|
||||||
|
@web_ns.doc(description="Get system feature flags and configuration")
|
||||||
|
@web_ns.doc(responses={200: "System features retrieved successfully", 500: "Internal server error"})
|
||||||
def get(self):
|
def get(self):
|
||||||
|
"""Get system feature flags and configuration.
|
||||||
|
|
||||||
|
Returns the current system feature flags and configuration
|
||||||
|
that control various functionalities across the platform.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: System feature configuration object
|
||||||
|
"""
|
||||||
return FeatureService.get_system_features().model_dump()
|
return FeatureService.get_system_features().model_dump()
|
||||||
|
|
||||||
|
|
||||||
api.add_resource(SystemFeatureApi, "/system-features")
|
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,50 @@ from controllers.common.errors import (
|
||||||
TooManyFilesError,
|
TooManyFilesError,
|
||||||
UnsupportedFileTypeError,
|
UnsupportedFileTypeError,
|
||||||
)
|
)
|
||||||
|
from controllers.web import web_ns
|
||||||
from controllers.web.wraps import WebApiResource
|
from controllers.web.wraps import WebApiResource
|
||||||
from fields.file_fields import file_fields
|
from fields.file_fields import build_file_model
|
||||||
from services.file_service import FileService
|
from services.file_service import FileService
|
||||||
|
|
||||||
|
|
||||||
|
@web_ns.route("/files/upload")
|
||||||
class FileApi(WebApiResource):
|
class FileApi(WebApiResource):
|
||||||
@marshal_with(file_fields)
|
@web_ns.doc("upload_file")
|
||||||
|
@web_ns.doc(description="Upload a file for use in web applications")
|
||||||
|
@web_ns.doc(
|
||||||
|
responses={
|
||||||
|
201: "File uploaded successfully",
|
||||||
|
400: "Bad request - invalid file or parameters",
|
||||||
|
413: "File too large",
|
||||||
|
415: "Unsupported file type",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@marshal_with(build_file_model(web_ns))
|
||||||
def post(self, app_model, end_user):
|
def post(self, app_model, end_user):
|
||||||
|
"""Upload a file for use in web applications.
|
||||||
|
|
||||||
|
Accepts file uploads for use within web applications, supporting
|
||||||
|
multiple file types with automatic validation and storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_model: The associated application model
|
||||||
|
end_user: The end user uploading the file
|
||||||
|
|
||||||
|
Form Parameters:
|
||||||
|
file: The file to upload (required)
|
||||||
|
source: Optional source type (datasets or None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: File information including ID, URL, and metadata
|
||||||
|
int: HTTP status code 201 for success
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NoFileUploadedError: No file provided in request
|
||||||
|
TooManyFilesError: Multiple files provided (only one allowed)
|
||||||
|
FilenameNotExistsError: File has no filename
|
||||||
|
FileTooLargeError: File exceeds size limit
|
||||||
|
UnsupportedFileTypeError: File type not supported
|
||||||
|
"""
|
||||||
if "file" not in request.files:
|
if "file" not in request.files:
|
||||||
raise NoFileUploadedError()
|
raise NoFileUploadedError()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ from controllers.console.auth.error import (
|
||||||
)
|
)
|
||||||
from controllers.console.error import EmailSendIpLimitError
|
from controllers.console.error import EmailSendIpLimitError
|
||||||
from controllers.console.wraps import email_password_login_enabled, only_edition_enterprise, setup_required
|
from controllers.console.wraps import email_password_login_enabled, only_edition_enterprise, setup_required
|
||||||
from controllers.web import api
|
from controllers.web import web_ns
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from libs.helper import email, extract_remote_ip
|
from libs.helper import email, extract_remote_ip
|
||||||
from libs.password import hash_password, valid_password
|
from libs.password import hash_password, valid_password
|
||||||
|
|
@ -24,10 +24,21 @@ from models.account import Account
|
||||||
from services.account_service import AccountService
|
from services.account_service import AccountService
|
||||||
|
|
||||||
|
|
||||||
|
@web_ns.route("/forgot-password")
|
||||||
class ForgotPasswordSendEmailApi(Resource):
|
class ForgotPasswordSendEmailApi(Resource):
|
||||||
@only_edition_enterprise
|
@only_edition_enterprise
|
||||||
@setup_required
|
@setup_required
|
||||||
@email_password_login_enabled
|
@email_password_login_enabled
|
||||||
|
@web_ns.doc("send_forgot_password_email")
|
||||||
|
@web_ns.doc(description="Send password reset email")
|
||||||
|
@web_ns.doc(
|
||||||
|
responses={
|
||||||
|
200: "Password reset email sent successfully",
|
||||||
|
400: "Bad request - invalid email format",
|
||||||
|
404: "Account not found",
|
||||||
|
429: "Too many requests - rate limit exceeded",
|
||||||
|
}
|
||||||
|
)
|
||||||
def post(self):
|
def post(self):
|
||||||
parser = reqparse.RequestParser()
|
parser = reqparse.RequestParser()
|
||||||
parser.add_argument("email", type=email, required=True, location="json")
|
parser.add_argument("email", type=email, required=True, location="json")
|
||||||
|
|
@ -54,10 +65,16 @@ class ForgotPasswordSendEmailApi(Resource):
|
||||||
return {"result": "success", "data": token}
|
return {"result": "success", "data": token}
|
||||||
|
|
||||||
|
|
||||||
|
@web_ns.route("/forgot-password/validity")
|
||||||
class ForgotPasswordCheckApi(Resource):
|
class ForgotPasswordCheckApi(Resource):
|
||||||
@only_edition_enterprise
|
@only_edition_enterprise
|
||||||
@setup_required
|
@setup_required
|
||||||
@email_password_login_enabled
|
@email_password_login_enabled
|
||||||
|
@web_ns.doc("check_forgot_password_token")
|
||||||
|
@web_ns.doc(description="Verify password reset token validity")
|
||||||
|
@web_ns.doc(
|
||||||
|
responses={200: "Token is valid", 400: "Bad request - invalid token format", 401: "Invalid or expired token"}
|
||||||
|
)
|
||||||
def post(self):
|
def post(self):
|
||||||
parser = reqparse.RequestParser()
|
parser = reqparse.RequestParser()
|
||||||
parser.add_argument("email", type=str, required=True, location="json")
|
parser.add_argument("email", type=str, required=True, location="json")
|
||||||
|
|
@ -94,10 +111,21 @@ class ForgotPasswordCheckApi(Resource):
|
||||||
return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
|
return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
|
||||||
|
|
||||||
|
|
||||||
|
@web_ns.route("/forgot-password/resets")
|
||||||
class ForgotPasswordResetApi(Resource):
|
class ForgotPasswordResetApi(Resource):
|
||||||
@only_edition_enterprise
|
@only_edition_enterprise
|
||||||
@setup_required
|
@setup_required
|
||||||
@email_password_login_enabled
|
@email_password_login_enabled
|
||||||
|
@web_ns.doc("reset_password")
|
||||||
|
@web_ns.doc(description="Reset user password with verification token")
|
||||||
|
@web_ns.doc(
|
||||||
|
responses={
|
||||||
|
200: "Password reset successfully",
|
||||||
|
400: "Bad request - invalid parameters or password mismatch",
|
||||||
|
401: "Invalid or expired token",
|
||||||
|
404: "Account not found",
|
||||||
|
}
|
||||||
|
)
|
||||||
def post(self):
|
def post(self):
|
||||||
parser = reqparse.RequestParser()
|
parser = reqparse.RequestParser()
|
||||||
parser.add_argument("token", type=str, required=True, nullable=False, location="json")
|
parser.add_argument("token", type=str, required=True, nullable=False, location="json")
|
||||||
|
|
@ -141,8 +169,3 @@ class ForgotPasswordResetApi(Resource):
|
||||||
account.password = base64.b64encode(password_hashed).decode()
|
account.password = base64.b64encode(password_hashed).decode()
|
||||||
account.password_salt = base64.b64encode(salt).decode()
|
account.password_salt = base64.b64encode(salt).decode()
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
api.add_resource(ForgotPasswordSendEmailApi, "/forgot-password")
|
|
||||||
api.add_resource(ForgotPasswordCheckApi, "/forgot-password/validity")
|
|
||||||
api.add_resource(ForgotPasswordResetApi, "/forgot-password/resets")
|
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,30 @@ from controllers.console.auth.error import (
|
||||||
)
|
)
|
||||||
from controllers.console.error import AccountBannedError
|
from controllers.console.error import AccountBannedError
|
||||||
from controllers.console.wraps import only_edition_enterprise, setup_required
|
from controllers.console.wraps import only_edition_enterprise, setup_required
|
||||||
from controllers.web import api
|
from controllers.web import web_ns
|
||||||
from libs.helper import email
|
from libs.helper import email
|
||||||
from libs.password import valid_password
|
from libs.password import valid_password
|
||||||
from services.account_service import AccountService
|
from services.account_service import AccountService
|
||||||
from services.webapp_auth_service import WebAppAuthService
|
from services.webapp_auth_service import WebAppAuthService
|
||||||
|
|
||||||
|
|
||||||
|
@web_ns.route("/login")
|
||||||
class LoginApi(Resource):
|
class LoginApi(Resource):
|
||||||
"""Resource for web app email/password login."""
|
"""Resource for web app email/password login."""
|
||||||
|
|
||||||
@setup_required
|
@setup_required
|
||||||
@only_edition_enterprise
|
@only_edition_enterprise
|
||||||
|
@web_ns.doc("web_app_login")
|
||||||
|
@web_ns.doc(description="Authenticate user for web application access")
|
||||||
|
@web_ns.doc(
|
||||||
|
responses={
|
||||||
|
200: "Authentication successful",
|
||||||
|
400: "Bad request - invalid email or password format",
|
||||||
|
401: "Authentication failed - email or password mismatch",
|
||||||
|
403: "Account banned or login disabled",
|
||||||
|
404: "Account not found",
|
||||||
|
}
|
||||||
|
)
|
||||||
def post(self):
|
def post(self):
|
||||||
"""Authenticate user and login."""
|
"""Authenticate user and login."""
|
||||||
parser = reqparse.RequestParser()
|
parser = reqparse.RequestParser()
|
||||||
|
|
@ -51,9 +63,19 @@ class LoginApi(Resource):
|
||||||
# return {"result": "success"}
|
# return {"result": "success"}
|
||||||
|
|
||||||
|
|
||||||
|
@web_ns.route("/email-code-login")
|
||||||
class EmailCodeLoginSendEmailApi(Resource):
|
class EmailCodeLoginSendEmailApi(Resource):
|
||||||
@setup_required
|
@setup_required
|
||||||
@only_edition_enterprise
|
@only_edition_enterprise
|
||||||
|
@web_ns.doc("send_email_code_login")
|
||||||
|
@web_ns.doc(description="Send email verification code for login")
|
||||||
|
@web_ns.doc(
|
||||||
|
responses={
|
||||||
|
200: "Email code sent successfully",
|
||||||
|
400: "Bad request - invalid email format",
|
||||||
|
404: "Account not found",
|
||||||
|
}
|
||||||
|
)
|
||||||
def post(self):
|
def post(self):
|
||||||
parser = reqparse.RequestParser()
|
parser = reqparse.RequestParser()
|
||||||
parser.add_argument("email", type=email, required=True, location="json")
|
parser.add_argument("email", type=email, required=True, location="json")
|
||||||
|
|
@ -74,9 +96,20 @@ class EmailCodeLoginSendEmailApi(Resource):
|
||||||
return {"result": "success", "data": token}
|
return {"result": "success", "data": token}
|
||||||
|
|
||||||
|
|
||||||
|
@web_ns.route("/email-code-login/validity")
|
||||||
class EmailCodeLoginApi(Resource):
|
class EmailCodeLoginApi(Resource):
|
||||||
@setup_required
|
@setup_required
|
||||||
@only_edition_enterprise
|
@only_edition_enterprise
|
||||||
|
@web_ns.doc("verify_email_code_login")
|
||||||
|
@web_ns.doc(description="Verify email code and complete login")
|
||||||
|
@web_ns.doc(
|
||||||
|
responses={
|
||||||
|
200: "Email code verified and login successful",
|
||||||
|
400: "Bad request - invalid code or token",
|
||||||
|
401: "Invalid token or expired code",
|
||||||
|
404: "Account not found",
|
||||||
|
}
|
||||||
|
)
|
||||||
def post(self):
|
def post(self):
|
||||||
parser = reqparse.RequestParser()
|
parser = reqparse.RequestParser()
|
||||||
parser.add_argument("email", type=str, required=True, location="json")
|
parser.add_argument("email", type=str, required=True, location="json")
|
||||||
|
|
@ -104,9 +137,3 @@ class EmailCodeLoginApi(Resource):
|
||||||
token = WebAppAuthService.login(account=account)
|
token = WebAppAuthService.login(account=account)
|
||||||
AccountService.reset_login_error_rate_limit(args["email"])
|
AccountService.reset_login_error_rate_limit(args["email"])
|
||||||
return {"result": "success", "data": {"access_token": token}}
|
return {"result": "success", "data": {"access_token": token}}
|
||||||
|
|
||||||
|
|
||||||
api.add_resource(LoginApi, "/login")
|
|
||||||
# api.add_resource(LogoutApi, "/logout")
|
|
||||||
api.add_resource(EmailCodeLoginSendEmailApi, "/email-code-login")
|
|
||||||
api.add_resource(EmailCodeLoginApi, "/email-code-login/validity")
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from sqlalchemy import func, select
|
||||||
from werkzeug.exceptions import NotFound, Unauthorized
|
from werkzeug.exceptions import NotFound, Unauthorized
|
||||||
|
|
||||||
from configs import dify_config
|
from configs import dify_config
|
||||||
from controllers.web import api
|
from controllers.web import web_ns
|
||||||
from controllers.web.error import WebAppAuthRequiredError
|
from controllers.web.error import WebAppAuthRequiredError
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from libs.passport import PassportService
|
from libs.passport import PassportService
|
||||||
|
|
@ -17,9 +17,19 @@ from services.feature_service import FeatureService
|
||||||
from services.webapp_auth_service import WebAppAuthService, WebAppAuthType
|
from services.webapp_auth_service import WebAppAuthService, WebAppAuthType
|
||||||
|
|
||||||
|
|
||||||
|
@web_ns.route("/passport")
|
||||||
class PassportResource(Resource):
|
class PassportResource(Resource):
|
||||||
"""Base resource for passport."""
|
"""Base resource for passport."""
|
||||||
|
|
||||||
|
@web_ns.doc("get_passport")
|
||||||
|
@web_ns.doc(description="Get authentication passport for web application access")
|
||||||
|
@web_ns.doc(
|
||||||
|
responses={
|
||||||
|
200: "Passport retrieved successfully",
|
||||||
|
401: "Unauthorized - missing app code or invalid authentication",
|
||||||
|
404: "Application or user not found",
|
||||||
|
}
|
||||||
|
)
|
||||||
def get(self):
|
def get(self):
|
||||||
system_features = FeatureService.get_system_features()
|
system_features = FeatureService.get_system_features()
|
||||||
app_code = request.headers.get("X-App-Code")
|
app_code = request.headers.get("X-App-Code")
|
||||||
|
|
@ -94,9 +104,6 @@ class PassportResource(Resource):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
api.add_resource(PassportResource, "/passport")
|
|
||||||
|
|
||||||
|
|
||||||
def decode_enterprise_webapp_user_id(jwt_token: str | None):
|
def decode_enterprise_webapp_user_id(jwt_token: str | None):
|
||||||
"""
|
"""
|
||||||
Decode the enterprise user session from the Authorization header.
|
Decode the enterprise user session from the Authorization header.
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,44 @@ from controllers.common.errors import (
|
||||||
RemoteFileUploadError,
|
RemoteFileUploadError,
|
||||||
UnsupportedFileTypeError,
|
UnsupportedFileTypeError,
|
||||||
)
|
)
|
||||||
|
from controllers.web import web_ns
|
||||||
from controllers.web.wraps import WebApiResource
|
from controllers.web.wraps import WebApiResource
|
||||||
from core.file import helpers as file_helpers
|
from core.file import helpers as file_helpers
|
||||||
from core.helper import ssrf_proxy
|
from core.helper import ssrf_proxy
|
||||||
from fields.file_fields import file_fields_with_signed_url, remote_file_info_fields
|
from fields.file_fields import build_file_with_signed_url_model, build_remote_file_info_model
|
||||||
from services.file_service import FileService
|
from services.file_service import FileService
|
||||||
|
|
||||||
|
|
||||||
|
@web_ns.route("/remote-files/<path:url>")
|
||||||
class RemoteFileInfoApi(WebApiResource):
|
class RemoteFileInfoApi(WebApiResource):
|
||||||
@marshal_with(remote_file_info_fields)
|
@web_ns.doc("get_remote_file_info")
|
||||||
|
@web_ns.doc(description="Get information about a remote file")
|
||||||
|
@web_ns.doc(
|
||||||
|
responses={
|
||||||
|
200: "Remote file information retrieved successfully",
|
||||||
|
400: "Bad request - invalid URL",
|
||||||
|
404: "Remote file not found",
|
||||||
|
500: "Failed to fetch remote file",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@marshal_with(build_remote_file_info_model(web_ns))
|
||||||
def get(self, app_model, end_user, url):
|
def get(self, app_model, end_user, url):
|
||||||
|
"""Get information about a remote file.
|
||||||
|
|
||||||
|
Retrieves basic information about a file located at a remote URL,
|
||||||
|
including content type and content length.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_model: The associated application model
|
||||||
|
end_user: The end user making the request
|
||||||
|
url: URL-encoded path to the remote file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Remote file information including type and length
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If the remote file cannot be accessed
|
||||||
|
"""
|
||||||
decoded_url = urllib.parse.unquote(url)
|
decoded_url = urllib.parse.unquote(url)
|
||||||
resp = ssrf_proxy.head(decoded_url)
|
resp = ssrf_proxy.head(decoded_url)
|
||||||
if resp.status_code != httpx.codes.OK:
|
if resp.status_code != httpx.codes.OK:
|
||||||
|
|
@ -32,9 +60,42 @@ class RemoteFileInfoApi(WebApiResource):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@web_ns.route("/remote-files/upload")
|
||||||
class RemoteFileUploadApi(WebApiResource):
|
class RemoteFileUploadApi(WebApiResource):
|
||||||
@marshal_with(file_fields_with_signed_url)
|
@web_ns.doc("upload_remote_file")
|
||||||
def post(self, app_model, end_user): # Add app_model and end_user parameters
|
@web_ns.doc(description="Upload a file from a remote URL")
|
||||||
|
@web_ns.doc(
|
||||||
|
responses={
|
||||||
|
201: "Remote file uploaded successfully",
|
||||||
|
400: "Bad request - invalid URL or parameters",
|
||||||
|
413: "File too large",
|
||||||
|
415: "Unsupported file type",
|
||||||
|
500: "Failed to fetch remote file",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@marshal_with(build_file_with_signed_url_model(web_ns))
|
||||||
|
def post(self, app_model, end_user):
|
||||||
|
"""Upload a file from a remote URL.
|
||||||
|
|
||||||
|
Downloads a file from the provided remote URL and uploads it
|
||||||
|
to the platform storage for use in web applications.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_model: The associated application model
|
||||||
|
end_user: The end user making the request
|
||||||
|
|
||||||
|
JSON Parameters:
|
||||||
|
url: The remote URL to download the file from (required)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: File information including ID, signed URL, and metadata
|
||||||
|
int: HTTP status code 201 for success
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RemoteFileUploadError: Failed to fetch file from remote URL
|
||||||
|
FileTooLargeError: File exceeds size limit
|
||||||
|
UnsupportedFileTypeError: File type not supported
|
||||||
|
"""
|
||||||
parser = reqparse.RequestParser()
|
parser = reqparse.RequestParser()
|
||||||
parser.add_argument("url", type=str, required=True, help="URL is required")
|
parser.add_argument("url", type=str, required=True, help="URL is required")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue