Compare commits
15 Commits
26b64f8c15
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f4ca52a10 | |||
| 62c33c47c6 | |||
| 692c0794c5 | |||
| 848c4b8fd8 | |||
| 1a160c9415 | |||
| 85d8124a0c | |||
| 84494827ad | |||
| 35c8934963 | |||
| cf247231e4 | |||
| 0186a95dd4 | |||
| fa855bc7bb | |||
| 6626fac452 | |||
| e7000f0b2e | |||
| 864b78641b | |||
| acb1e2260f |
@@ -2,10 +2,6 @@ FROM python:3.12-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
WORKDIR /app
|
||||
#RUN apt-get update &&\
|
||||
# apt-get install -y default-mysql-client &&\
|
||||
# apt-get clean &&\
|
||||
# rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r ./requirements.txt
|
||||
|
||||
114
api/__init__.py
114
api/__init__.py
@@ -1,23 +1,25 @@
|
||||
import base64
|
||||
import os
|
||||
from functools import wraps
|
||||
|
||||
from datetime import datetime, UTC
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from flask import jsonify, Blueprint, request, make_response
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from jwt import decode, ExpiredSignatureError, InvalidTokenError, get_unverified_header
|
||||
import importlib
|
||||
import requests
|
||||
from threading import Lock
|
||||
from db.models.APIKey import APIKey
|
||||
from db import get_db
|
||||
|
||||
import base64
|
||||
import os
|
||||
import pkgutil
|
||||
import secrets
|
||||
import string
|
||||
import env_provider
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
|
||||
|
||||
import importlib
|
||||
import requests
|
||||
|
||||
_public_key_cache = {}
|
||||
_lock = Lock()
|
||||
@@ -41,20 +43,19 @@ def get_jwks():
|
||||
|
||||
def get_public_key_for_kid(kid):
|
||||
global _public_key_cache
|
||||
if kid in _public_key_cache:
|
||||
return _public_key_cache[kid]
|
||||
jwks = get_jwks()
|
||||
res = []
|
||||
for key_data in jwks["keys"]:
|
||||
if key_data["kid"] == kid and key_data["use"] == "sig" and key_data["alg"] == "RS256" and key_data["kty"] == "RSA":
|
||||
x5c = key_data["x5c"][0]
|
||||
pem_public_key = x5c_to_public_key(x5c)
|
||||
_public_key_cache[kid] = pem_public_key
|
||||
res.append(pem_public_key)
|
||||
if len(res) > 0:
|
||||
print(len(res))
|
||||
return res[0]
|
||||
|
||||
with _lock:
|
||||
if kid in _public_key_cache:
|
||||
return _public_key_cache[kid]
|
||||
jwks = get_jwks()
|
||||
res = []
|
||||
for key_data in jwks["keys"]:
|
||||
if key_data["kid"] == kid and key_data["use"] == "sig" and key_data["alg"] == "RS256" and key_data["kty"] == "RSA":
|
||||
x5c = key_data["x5c"][0]
|
||||
pem_public_key = x5c_to_public_key(x5c)
|
||||
_public_key_cache[kid] = pem_public_key
|
||||
res.append(pem_public_key)
|
||||
if len(res) > 0:
|
||||
return res[0]
|
||||
return None
|
||||
|
||||
|
||||
@@ -81,24 +82,59 @@ def verify_token(token):
|
||||
print(e)
|
||||
return None
|
||||
|
||||
def is_user_admin():
|
||||
is_admin = False
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header and auth_header.startswith('Bearer'):
|
||||
token = auth_header.split(" ")[1]
|
||||
decoded = verify_token(token)
|
||||
if decoded:
|
||||
user_roles = decoded.get("resource_access", {}).get(env_provider.KC_CLIENT_ID, {}).get("roles", [])
|
||||
is_admin = 'admin' in user_roles
|
||||
return is_admin
|
||||
|
||||
def require_auth(roles=[]):
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if request.method == "OPTIONS":
|
||||
return '', 200
|
||||
|
||||
auth_header = request.headers.get('Authorization')
|
||||
api_key_header = request.headers.get('X-API-Key')
|
||||
|
||||
if auth_header and api_key_header:
|
||||
return jsonify({"error": "Cannot use both Bearer token and API Key authentication"}), 403
|
||||
|
||||
if api_key_header:
|
||||
api_key = get_api_key(api_key_header)
|
||||
if not api_key:
|
||||
return jsonify({"error": "Invalid API key"}), 401
|
||||
|
||||
expire_time = api_key.expire.replace(tzinfo=UTC) if api_key.expire.tzinfo is None else api_key.expire
|
||||
if datetime.now(UTC) > expire_time:
|
||||
return jsonify({"error": "API key has expired"}), 401
|
||||
|
||||
if roles and not (set(roles) & set(api_key.roles)):
|
||||
return jsonify({"error": "Forbidden, permission denied"}), 403
|
||||
|
||||
update_last_used(api_key)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
if not auth_header or not auth_header.startswith('Bearer'):
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
|
||||
decoded = verify_token(token)
|
||||
if not decoded:
|
||||
return jsonify({"error": "Invalid or expired token"}), 401
|
||||
|
||||
user_roles = decoded.get("resource_access", {}).get(env_provider.KC_CLIENT_ID, {}).get("roles", [])
|
||||
if roles and not (set(roles) & set(user_roles)):
|
||||
print("auth failed")
|
||||
return jsonify({"error": "Forbidden, permission denied"}), 403
|
||||
|
||||
print("auth success")
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
@@ -127,17 +163,18 @@ limiter = Limiter(
|
||||
|
||||
|
||||
def register_blueprints(app):
|
||||
current_dir = os.path.dirname(__file__)
|
||||
for filename in os.listdir(current_dir):
|
||||
if filename == "__init__.py" or not filename.endswith(".py"):
|
||||
continue
|
||||
module_name = filename[:-3]
|
||||
module = importlib.import_module(f"api.{module_name}")
|
||||
for attr in dir(module):
|
||||
bp = getattr(module, attr)
|
||||
if isinstance(bp, Blueprint):
|
||||
app.register_blueprint(bp)
|
||||
|
||||
package_name = __name__
|
||||
package_path = os.path.dirname(__file__)
|
||||
|
||||
for finder, mod_name, is_pkg in pkgutil.walk_packages([package_path], package_name + "."):
|
||||
module = importlib.import_module(mod_name)
|
||||
for attr_name in dir(module):
|
||||
item = getattr(module, attr_name)
|
||||
if isinstance(item, Blueprint):
|
||||
if item.name in app.blueprints:
|
||||
continue
|
||||
app.register_blueprint(item)
|
||||
|
||||
|
||||
def generate_etag(data):
|
||||
@@ -160,3 +197,16 @@ def etag_response(f):
|
||||
return response
|
||||
return decorator
|
||||
|
||||
|
||||
def generate_api_key(length=32):
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
def get_api_key(key):
|
||||
with get_db() as session:
|
||||
return session.query(APIKey).filter_by(key=key, is_active=True).first()
|
||||
|
||||
def update_last_used(api_key):
|
||||
with get_db() as session:
|
||||
api_key.last_used_at = datetime.now(UTC)
|
||||
session.commit()
|
||||
36
api/apikey/__init__.py
Normal file
36
api/apikey/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from api import get_api_key, generate_api_key
|
||||
from db import get_db
|
||||
from api import require_auth
|
||||
from db.models.APIKey import APIKey
|
||||
|
||||
api_key_bp = Blueprint('apikey', __name__, url_prefix='/api/apikey')
|
||||
|
||||
@api_key_bp.route('/', methods=['POST'])
|
||||
@require_auth(roles=['admin'])
|
||||
def create_key():
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'name' not in data:
|
||||
return jsonify({"error": "Name is required"}), 400
|
||||
roles = data.get('roles', [])
|
||||
try:
|
||||
with get_db() as session:
|
||||
apikey = APIKey(key=generate_api_key(),name=data['name'], roles=roles)
|
||||
session.add(apikey);
|
||||
session.commit
|
||||
return jsonify(apikey.to_dict()), 201
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@api_key_bp.route('/<key>', methods=['DELETE'])
|
||||
@require_auth(roles=['admin'])
|
||||
def revoke_key(key):
|
||||
|
||||
api_key = get_api_key(key)
|
||||
with get_db() as session:
|
||||
if not api_key:
|
||||
return jsonify({"error": "API key not found"}), 404
|
||||
api_key.is_active = False
|
||||
session.commit()
|
||||
return jsonify({"message": "API key revoked successfully"}), 200
|
||||
827
api/backup.py
827
api/backup.py
@@ -1,26 +1,363 @@
|
||||
"""
|
||||
Backup and Restore System
|
||||
=========================
|
||||
|
||||
This module provides functionality for creating backups of the application's data and restoring from those backups.
|
||||
|
||||
Backup Structure
|
||||
---------------
|
||||
The backup is a zip file containing the following structure:
|
||||
- Root/
|
||||
- tree/ # Contains the entire path hierarchy and markdown files
|
||||
- .json.meta # Metadata for the root path
|
||||
- [markdown].json # Markdown files directly under root
|
||||
- [folder]/ # Subfolders representing paths
|
||||
- .json.meta # Metadata for this path
|
||||
- [markdown].json # Markdown files in this path
|
||||
- template/ # Contains markdown templates
|
||||
- [template].json # One JSON file per template, using title as filename
|
||||
- webhook.json # Contains all webhooks and their settings
|
||||
|
||||
Data Format
|
||||
-----------
|
||||
1. Markdown files (.json):
|
||||
{
|
||||
"title": "...", # Title of the markdown
|
||||
"content": "...", # Content of the markdown
|
||||
"created_at": "...", # Creation timestamp
|
||||
"order": "...", # Order value for sorting
|
||||
"shortcut": "...", # Shortcut value if any
|
||||
"backup_id": ..., # Reference ID for the backup
|
||||
"setting": { # Optional settings object
|
||||
"permission_setting": "...", # Permission value (private, protected, etc.)
|
||||
"template_setting": { # Template settings if any
|
||||
"template_id": ..., # Template ID reference
|
||||
"template_ref": { # Reference to the template
|
||||
"title": "..." # Title of the template for lookup
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2. Path metadata (.json.meta):
|
||||
{
|
||||
"name": "...", # Name of the path
|
||||
"order": "...", # Order value for sorting
|
||||
"backup_id": ..., # Reference ID for the backup
|
||||
"webhook_setting": { # Optional webhook settings
|
||||
"recursive": true/false, # Whether webhook applies to subpaths
|
||||
"additional_header": "...", # Additional headers for webhook
|
||||
"enabled": true/false, # Whether webhook is enabled
|
||||
"on_events": ... # Event types that trigger the webhook
|
||||
},
|
||||
"webhook_ref": { # Reference to the webhook
|
||||
"hook_url": "..." # URL of the webhook for lookup
|
||||
},
|
||||
"template_setting": { # Template settings if any
|
||||
"template_id": ... # Template ID reference
|
||||
},
|
||||
"template_ref": { # Reference to the template
|
||||
"title": "..." # Title of the template for lookup
|
||||
}
|
||||
}
|
||||
|
||||
3. Template files (template/[name].json):
|
||||
{
|
||||
"title": "...", # Title of the template
|
||||
"parameters": {...}, # Parameters for the template
|
||||
"layout": "..." # Layout content of the template
|
||||
}
|
||||
|
||||
4. Webhook file (webhook.json):
|
||||
[
|
||||
{
|
||||
"backup_id": ..., # Reference ID for the backup
|
||||
"hook_url": "...", # URL of the webhook
|
||||
"settings": [ # Array of settings for this webhook
|
||||
{
|
||||
"recursive": true/false, # Whether webhook applies to subpaths
|
||||
"additional_header": "...", # Additional headers for webhook
|
||||
"enabled": true/false, # Whether webhook is enabled
|
||||
"on_events": ... # Event types that trigger the webhook
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
How to Add New Information to Backup
|
||||
-----------------------------------
|
||||
To add new information to the backup system, follow these steps:
|
||||
|
||||
1. Adding a new field to an existing entity:
|
||||
- For Path entities: Add the field to the path metadata in the traverse() function
|
||||
- For Markdown entities: Add the field to the md_data dictionary in the traverse() function
|
||||
- For Templates: Add the field to the template_dict in export_templates()
|
||||
- For Webhooks: Add the field to the webhook_entry in export_webhooks()
|
||||
|
||||
2. Adding a new entity type:
|
||||
- Create a new export_[entity]() function similar to export_webhooks() or export_templates()
|
||||
- Call this function from get_backup()
|
||||
- Create a corresponding import_[entity]() function for restoration
|
||||
- Call this function from load_backup()
|
||||
|
||||
3. Example: Adding a new "tags" field to markdown:
|
||||
In the traverse() function, modify the md_data creation:
|
||||
|
||||
md_data = {
|
||||
"title": md.title,
|
||||
"content": md.content,
|
||||
"created_at": md.created_at,
|
||||
"order": md.order,
|
||||
"shortcut": md.shortcut,
|
||||
"backup_id": md.id,
|
||||
"tags": md.tags # New field
|
||||
}
|
||||
|
||||
|
||||
Then in process_markdown_file(), handle the new field:
|
||||
|
||||
tags = md_data.get("tags", []) # Get tags with default empty list
|
||||
|
||||
# Later when creating/updating the markdown:
|
||||
if existing_md:
|
||||
existing_md.tags = tags
|
||||
else:
|
||||
new_md = Markdown(
|
||||
# other fields...
|
||||
tags=tags
|
||||
)
|
||||
|
||||
|
||||
4. Example: Adding a new entity type "Comments":
|
||||
Create export function:
|
||||
|
||||
def export_comments():
|
||||
# Export all comments to comments.json file in the root directory
|
||||
with get_db() as session:
|
||||
comments = session.query(Comment).all()
|
||||
comment_data = []
|
||||
|
||||
for comment in comments:
|
||||
comment_dict = comment.to_dict()
|
||||
# Process and add to comment_data
|
||||
|
||||
with open('comments.json', 'w') as f:
|
||||
json.dump(comment_data, f, default=str, indent=2)
|
||||
|
||||
|
||||
Call it from get_backup():
|
||||
|
||||
# After other exports
|
||||
export_comments()
|
||||
|
||||
|
||||
Create import function:
|
||||
|
||||
def import_comments(comments_file, session):
|
||||
# Logic to import comments
|
||||
|
||||
|
||||
Call it from load_backup():
|
||||
|
||||
# After other imports
|
||||
import_comments(os.path.join(root_dir, "comments.json"), session)
|
||||
|
||||
|
||||
Maintaining Backward Compatibility
|
||||
---------------------------------
|
||||
When adding new fields or entities:
|
||||
1. Always use .get() with default values when reading JSON data
|
||||
2. Check if fields exist before accessing them
|
||||
3. Handle both old and new formats in import functions
|
||||
4. Use conditional logic to process data based on available fields
|
||||
5. Keep the basic structure of the backup intact
|
||||
|
||||
For example, in process_markdown_file():
|
||||
|
||||
# Handle both old and new formats
|
||||
if "setting" in md_data:
|
||||
# Process new format
|
||||
else:
|
||||
# Process old format for backward compatibility
|
||||
|
||||
|
||||
ID Handling
|
||||
----------
|
||||
The backup system maintains its own ID references:
|
||||
1. Database IDs are not directly used in the backup
|
||||
2. Each entity gets a backup_id for reference within the backup
|
||||
3. When restoring, new database IDs are generated
|
||||
4. References between entities use lookup by natural keys (e.g., title, URL)
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, send_file, jsonify
|
||||
import tempfile
|
||||
import zipfile
|
||||
from flask import Blueprint, send_file, jsonify, request
|
||||
import os
|
||||
import json
|
||||
from api import require_auth
|
||||
from db import get_db
|
||||
from db.models.Markdown import Markdown
|
||||
from db.models.Path import Path
|
||||
|
||||
from db.models.MarkdownSetting import MarkdownSetting
|
||||
from db.models.MarkdownTemplateSetting import MarkdownTemplateSetting
|
||||
from db.models.MarkdownPermissionSetting import MarkdownPermissionSetting
|
||||
from db.models.MarkdownTemplate import MarkdownTemplate
|
||||
from db.models.PathSetting import PathSetting
|
||||
from db.models.WebhookSetting import WebhookSetting
|
||||
from db.models.PathTemplate import PathTemplate
|
||||
from db.models.Webhook import Webhook
|
||||
import threading
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
backup_bp = Blueprint('backup', __name__, url_prefix='/api/backup')
|
||||
|
||||
def check_and_convert_backup_version(backup_dir):
|
||||
"""
|
||||
Check the backup version and convert it if necessary.
|
||||
|
||||
Args:
|
||||
backup_dir (str): Path to the backup directory
|
||||
|
||||
Returns:
|
||||
tuple: (success, error_response)
|
||||
- success (bool): True if the check and conversion was successful, False otherwise
|
||||
- error_response: None if successful, otherwise a Flask response object with an error message
|
||||
"""
|
||||
from misc.backup_converters import get_backup_version, CURRENT_VERSION, convert_backup
|
||||
backup_version = get_backup_version(backup_dir)
|
||||
|
||||
if backup_version != CURRENT_VERSION:
|
||||
logger.info(f"Converting backup from version {backup_version} to {CURRENT_VERSION}")
|
||||
try:
|
||||
convert_backup(backup_dir, CURRENT_VERSION)
|
||||
return True, None
|
||||
except ValueError as e:
|
||||
logger.error(f"Failed to convert backup: {e}")
|
||||
return False, jsonify({"error": f"Failed to convert backup: {e}"}), 400
|
||||
|
||||
return True, None
|
||||
|
||||
@backup_bp.route('/convert', methods=['POST'])
|
||||
@require_auth(roles=['admin'])
|
||||
def convert_backup_endpoint():
|
||||
"""
|
||||
Convert an old version backup to the current version format.
|
||||
|
||||
This endpoint accepts an uploaded backup file, converts it to the current version format,
|
||||
and returns the converted backup file as an attachment. The conversion process handles the differences
|
||||
between different backup formats, including the directory structure, file formats, and metadata.
|
||||
|
||||
Request:
|
||||
- file: The backup file to convert (multipart/form-data)
|
||||
|
||||
Returns:
|
||||
The converted backup file as an attachment.
|
||||
|
||||
Response Codes:
|
||||
- 200: Conversion successful
|
||||
- 400: No file provided or invalid file
|
||||
- 429: Another backup operation is in progress
|
||||
- 500: Conversion failed
|
||||
"""
|
||||
if not backup_lock.acquire(blocking=False):
|
||||
return jsonify({"error": "Another backup operation is in progress. Please try again later."}), 429
|
||||
|
||||
try:
|
||||
if 'file' not in request.files:
|
||||
return jsonify({"error": "No file provided"}), 400
|
||||
|
||||
uploaded_file = request.files['file']
|
||||
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
backup_dir = os.path.join(temp_dir, "backup")
|
||||
os.makedirs(backup_dir)
|
||||
|
||||
zip_path = os.path.join(temp_dir, "backup.zip")
|
||||
uploaded_file.save(zip_path)
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(backup_dir)
|
||||
|
||||
success, error_response = check_and_convert_backup_version(backup_dir)
|
||||
if not success:
|
||||
return error_response
|
||||
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
|
||||
archive_name = f"converted_backup_{timestamp}"
|
||||
archive_path = shutil.make_archive(
|
||||
base_name=os.path.join(temp_dir, archive_name),
|
||||
format='zip',
|
||||
root_dir=backup_dir
|
||||
)
|
||||
|
||||
shutil.rmtree(backup_dir)
|
||||
|
||||
return send_file(
|
||||
archive_path,
|
||||
as_attachment=True,
|
||||
download_name=f"{archive_name}.zip",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to convert backup: {e}")
|
||||
return jsonify({"error": f"Failed to convert backup: {e}"}), 500
|
||||
|
||||
finally:
|
||||
backup_lock.release()
|
||||
|
||||
|
||||
backup_lock = threading.Lock()
|
||||
@backup_bp.route('/', methods=['GET'])
|
||||
@require_auth(roles=['admin'])
|
||||
def get_backup():
|
||||
"""
|
||||
Create a backup of the application's data.
|
||||
|
||||
This function creates a backup of the application's data, including:
|
||||
- The tree structure (paths and markdowns)
|
||||
- Templates
|
||||
- Webhooks
|
||||
- Version information
|
||||
|
||||
The backup is returned as a zip file attachment.
|
||||
|
||||
Returns:
|
||||
A zip file containing the backup data.
|
||||
|
||||
Response Codes:
|
||||
- 200: Backup created successfully
|
||||
- 500: Failed to create backup
|
||||
"""
|
||||
try:
|
||||
if os.path.exists('Root'):
|
||||
shutil.rmtree('Root')
|
||||
os.makedirs('Root')
|
||||
os.chdir('Root')
|
||||
|
||||
os.makedirs('tree')
|
||||
os.makedirs('template')
|
||||
|
||||
from misc.backup_converters import CURRENT_VERSION
|
||||
with open('version.json', 'w') as f:
|
||||
json.dump({"version": CURRENT_VERSION}, f, indent=2)
|
||||
|
||||
export_webhooks()
|
||||
export_templates()
|
||||
|
||||
paths = {}
|
||||
with get_db() as session:
|
||||
pths = session.query(Path).all()
|
||||
paths = {p.id : p for p in pths}
|
||||
|
||||
os.chdir('tree')
|
||||
traverse(1, paths)
|
||||
os.chdir('..')
|
||||
|
||||
os.chdir('..')
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
|
||||
archive = shutil.make_archive(base_name=timestamp, format='zip', root_dir='Root')
|
||||
|
||||
@@ -43,24 +380,484 @@ def create_and_cd(path_name):
|
||||
def cd_back():
|
||||
os.chdir('..')
|
||||
|
||||
def export_webhooks():
|
||||
with get_db() as session:
|
||||
webhooks = session.query(Webhook).all()
|
||||
webhook_data = []
|
||||
|
||||
for webhook in webhooks:
|
||||
webhook_dict = webhook.to_dict()
|
||||
backup_id = len(webhook_data) + 1
|
||||
|
||||
webhook_settings = session.query(WebhookSetting).filter_by(webhook_id=webhook.id).all()
|
||||
settings_list = []
|
||||
|
||||
for setting in webhook_settings:
|
||||
setting_dict = setting.to_dict()
|
||||
setting_dict.pop('id', None)
|
||||
setting_dict.pop('webhook_id', None)
|
||||
settings_list.append(setting_dict)
|
||||
|
||||
webhook_entry = {
|
||||
'backup_id': backup_id,
|
||||
'hook_url': webhook_dict['hook_url'],
|
||||
'settings': settings_list
|
||||
}
|
||||
webhook_data.append(webhook_entry)
|
||||
|
||||
with open('webhook.json', 'w') as f:
|
||||
json.dump(webhook_data, f, default=str, indent=2)
|
||||
|
||||
def export_templates():
|
||||
with get_db() as session:
|
||||
templates = session.query(MarkdownTemplate).all()
|
||||
|
||||
for template in templates:
|
||||
template_dict = template.to_dict()
|
||||
filename = f"{template_dict['title']}.json"
|
||||
|
||||
template_dict.pop('id', None)
|
||||
|
||||
with open(os.path.join('template', filename), 'w') as f:
|
||||
json.dump(template_dict, f, default=str, indent=2)
|
||||
|
||||
def traverse(path_id, paths):
|
||||
current_path = paths[path_id]
|
||||
if path_id == 1:
|
||||
create_and_cd("Root")
|
||||
else:
|
||||
if path_id != 1:
|
||||
create_and_cd(current_path.name)
|
||||
with open(".meta", "w") as meta_file:
|
||||
meta_file.write(f"order: {current_path.order}\n")
|
||||
|
||||
with get_db() as session:
|
||||
path_meta = {
|
||||
"name": current_path.name,
|
||||
"order": current_path.order,
|
||||
"backup_id": path_id
|
||||
}
|
||||
|
||||
if current_path.setting_id:
|
||||
path_setting = session.query(PathSetting).get(current_path.setting_id)
|
||||
if path_setting:
|
||||
if path_setting.webhook_setting_id:
|
||||
webhook_setting = session.query(WebhookSetting).get(path_setting.webhook_setting_id)
|
||||
if webhook_setting:
|
||||
if webhook_setting.webhook_id:
|
||||
webhook = session.query(Webhook).get(webhook_setting.webhook_id)
|
||||
if webhook:
|
||||
path_meta["webhook_ref"] = {
|
||||
"hook_url": webhook.hook_url
|
||||
}
|
||||
|
||||
path_meta["webhook_setting"] = {
|
||||
"recursive": webhook_setting.recursive,
|
||||
"additional_header": webhook_setting.additional_header,
|
||||
"enabled": webhook_setting.enabled,
|
||||
"on_events": webhook_setting.on_events
|
||||
}
|
||||
|
||||
if path_setting.template_setting_id:
|
||||
path_template = session.query(PathTemplate).get(path_setting.template_setting_id)
|
||||
if path_template:
|
||||
path_meta["template_ref"] = {
|
||||
"title": path_template.title
|
||||
}
|
||||
path_meta["template_setting"] = {
|
||||
"template_id": path_setting.template_setting_id
|
||||
}
|
||||
|
||||
with open(".json.meta", "w") as meta_file:
|
||||
json.dump(path_meta, meta_file, default=str, indent=2)
|
||||
|
||||
mds = session.query(Markdown).filter(Markdown.path_id == path_id).all()
|
||||
for md in mds:
|
||||
with open(f"{md.title}.md", "w") as md_file:
|
||||
md_file.write(md.content)
|
||||
with open(f"{md.title}.mdmeta", "w") as meta_file:
|
||||
meta_file.write(f"created_at: {md.created_at}\n")
|
||||
meta_file.write(f"order: {md.order}\n")
|
||||
meta_file.write(f"shortcut: {md.shortcut}\n")
|
||||
md_data = {
|
||||
"title": md.title,
|
||||
"content": md.content,
|
||||
"created_at": md.created_at,
|
||||
"order": md.order,
|
||||
"shortcut": md.shortcut,
|
||||
"backup_id": md.id
|
||||
}
|
||||
|
||||
if md.setting_id:
|
||||
md_setting = session.query(MarkdownSetting).get(md.setting_id)
|
||||
if md_setting:
|
||||
settings = {}
|
||||
|
||||
if md_setting.template_setting_id:
|
||||
template_setting = session.query(MarkdownTemplateSetting).get(md_setting.template_setting_id)
|
||||
if template_setting and template_setting.template_id:
|
||||
template = session.query(MarkdownTemplate).get(template_setting.template_id)
|
||||
if template:
|
||||
settings["template_setting"] = {
|
||||
"template_id": template_setting.template_id,
|
||||
"template_ref": {
|
||||
"title": template.title
|
||||
}
|
||||
}
|
||||
|
||||
if md_setting.permission_setting_id:
|
||||
permission_setting = session.query(MarkdownPermissionSetting).get(md_setting.permission_setting_id)
|
||||
if permission_setting:
|
||||
settings["permission_setting"] = permission_setting.permission
|
||||
|
||||
if settings:
|
||||
md_data["setting"] = settings
|
||||
|
||||
with open(f"{md.title}.json", "w") as md_file:
|
||||
json.dump(md_data, md_file, default=str, indent=2)
|
||||
|
||||
children = [c for c in paths.values() if c.parent_id == path_id]
|
||||
for child in children:
|
||||
traverse(child.id, paths)
|
||||
cd_back()
|
||||
|
||||
if path_id != 1:
|
||||
cd_back()
|
||||
|
||||
|
||||
@backup_bp.route('/load', methods=['POST'])
|
||||
@require_auth(roles=['admin'])
|
||||
def load_backup():
|
||||
"""
|
||||
Restore data from a backup file.
|
||||
|
||||
This function restores data from a backup file, including:
|
||||
- The tree structure (paths and markdowns)
|
||||
- Templates
|
||||
- Webhooks
|
||||
|
||||
If the backup version does not match the current version, the backup will be
|
||||
automatically converted to the current version before being restored.
|
||||
|
||||
Request:
|
||||
- file: The backup file to restore (multipart/form-data)
|
||||
|
||||
Returns:
|
||||
A JSON object with a success message.
|
||||
|
||||
Response Codes:
|
||||
- 200: Backup restored successfully
|
||||
- 400: No file provided or invalid backup format
|
||||
- 429: Another backup restore is in progress
|
||||
- 500: Failed to restore backup
|
||||
"""
|
||||
if not backup_lock.acquire(blocking=False):
|
||||
return jsonify({"error": "Another backup restore is in progress. Please try again later."}), 429
|
||||
|
||||
try:
|
||||
if 'file' not in request.files:
|
||||
return jsonify({"error": "No file provided"}), 400
|
||||
uploaded_file = request.files['file']
|
||||
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
zip_path = os.path.join(temp_dir, "backup.zip")
|
||||
uploaded_file.save(zip_path)
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(temp_dir)
|
||||
|
||||
root_dir = os.path.join(temp_dir, "Root")
|
||||
if not os.path.exists(root_dir):
|
||||
root_dir = temp_dir
|
||||
|
||||
success, error_response = check_and_convert_backup_version(root_dir)
|
||||
if not success:
|
||||
return error_response
|
||||
|
||||
tree_dir = os.path.join(root_dir, "tree")
|
||||
template_dir = os.path.join(root_dir, "template")
|
||||
|
||||
if not os.path.exists(tree_dir) or not os.path.exists(template_dir):
|
||||
return jsonify({"error": "Invalid backup format: missing tree or template directory"}), 400
|
||||
|
||||
with get_db() as session:
|
||||
import_templates(template_dir, session)
|
||||
|
||||
webhook_mapping = import_webhooks(os.path.join(root_dir, "webhook.json"), session)
|
||||
|
||||
path_mapping = {}
|
||||
restore_tree(tree_dir, None, session, path_mapping, webhook_mapping)
|
||||
|
||||
session.commit()
|
||||
|
||||
shutil.rmtree(temp_dir)
|
||||
return jsonify({"success": True, "message": "Backup restored and merged successfully"}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load backup: {e}")
|
||||
return jsonify({"error": f"Failed to load backup {e}"}), 500
|
||||
finally:
|
||||
backup_lock.release()
|
||||
|
||||
|
||||
def import_templates(template_dir, session):
|
||||
template_mapping = {}
|
||||
|
||||
for filename in os.listdir(template_dir):
|
||||
if filename.endswith('.json'):
|
||||
file_path = os.path.join(template_dir, filename)
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
template_data = json.load(f)
|
||||
|
||||
title = template_data.get('title')
|
||||
if not title:
|
||||
title = os.path.splitext(filename)[0]
|
||||
|
||||
existing_template = session.query(MarkdownTemplate).filter_by(title=title).first()
|
||||
if existing_template:
|
||||
template_mapping[title] = existing_template.id
|
||||
else:
|
||||
new_template = MarkdownTemplate(
|
||||
title=title,
|
||||
parameters=template_data.get('parameters'),
|
||||
layout=template_data.get('layout')
|
||||
)
|
||||
session.add(new_template)
|
||||
session.flush()
|
||||
template_mapping[title] = new_template.id
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing template {filename}: {e}")
|
||||
|
||||
return template_mapping
|
||||
|
||||
def import_webhooks(webhook_file, session):
|
||||
webhook_mapping = {}
|
||||
|
||||
if not os.path.exists(webhook_file):
|
||||
logger.warning(f"Webhook file not found: {webhook_file}")
|
||||
return webhook_mapping
|
||||
|
||||
try:
|
||||
with open(webhook_file, 'r') as f:
|
||||
webhook_data = json.load(f)
|
||||
|
||||
for webhook_entry in webhook_data:
|
||||
backup_id = webhook_entry.get('backup_id')
|
||||
hook_url = webhook_entry.get('hook_url')
|
||||
|
||||
if not hook_url:
|
||||
continue
|
||||
|
||||
existing_webhook = session.query(Webhook).filter_by(hook_url=hook_url).first()
|
||||
if existing_webhook:
|
||||
webhook_id = existing_webhook.id
|
||||
else:
|
||||
new_webhook = Webhook(hook_url=hook_url)
|
||||
session.add(new_webhook)
|
||||
session.flush()
|
||||
webhook_id = new_webhook.id
|
||||
|
||||
webhook_mapping[backup_id] = webhook_id
|
||||
|
||||
settings = webhook_entry.get('settings', [])
|
||||
for setting_data in settings:
|
||||
new_setting = WebhookSetting(
|
||||
webhook_id=webhook_id,
|
||||
recursive=setting_data.get('recursive', False),
|
||||
additional_header=setting_data.get('additional_header'),
|
||||
enabled=setting_data.get('enabled', True),
|
||||
on_events=setting_data.get('on_events', 0)
|
||||
)
|
||||
session.add(new_setting)
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing webhooks: {e}")
|
||||
|
||||
return webhook_mapping
|
||||
|
||||
def process_markdown_file(file_path, file_name, new_path_id, session):
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
md_data = json.load(f)
|
||||
|
||||
md_title = md_data.get("title")
|
||||
if not md_title:
|
||||
md_title = os.path.splitext(file_name)[0]
|
||||
|
||||
content = md_data.get("content", "")
|
||||
created_at_str = md_data.get("created_at")
|
||||
created_at = datetime.now()
|
||||
if created_at_str:
|
||||
try:
|
||||
created_at = datetime.strptime(created_at_str, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
order = md_data.get("order", "")
|
||||
shortcut = md_data.get("shortcut", "")
|
||||
|
||||
setting_id = None
|
||||
template_setting_id = None
|
||||
permission_setting_id = None
|
||||
|
||||
if "setting" in md_data:
|
||||
settings = md_data.get("setting", {})
|
||||
|
||||
if "template_setting" in settings:
|
||||
template_setting_data = settings["template_setting"]
|
||||
template_title = None
|
||||
|
||||
if isinstance(template_setting_data, dict) and "template_ref" in template_setting_data:
|
||||
template_title = template_setting_data["template_ref"].get("title")
|
||||
|
||||
if template_title:
|
||||
existing_template = session.query(MarkdownTemplate).filter_by(title=template_title).first()
|
||||
if existing_template:
|
||||
template_id = existing_template.id
|
||||
new_template_setting = MarkdownTemplateSetting(template_id=template_id)
|
||||
session.add(new_template_setting)
|
||||
session.flush()
|
||||
template_setting_id = new_template_setting.id
|
||||
|
||||
if "permission_setting" in settings:
|
||||
permission = settings["permission_setting"]
|
||||
if permission:
|
||||
new_permission_setting = MarkdownPermissionSetting(permission=permission)
|
||||
session.add(new_permission_setting)
|
||||
session.flush()
|
||||
permission_setting_id = new_permission_setting.id
|
||||
else:
|
||||
if "template_ref" in md_data:
|
||||
template_title = md_data["template_ref"].get("title")
|
||||
if template_title:
|
||||
existing_template = session.query(MarkdownTemplate).filter_by(title=template_title).first()
|
||||
if existing_template:
|
||||
template_id = existing_template.id
|
||||
if "template_setting" in md_data:
|
||||
pass
|
||||
new_template_setting = MarkdownTemplateSetting(template_id=template_id)
|
||||
session.add(new_template_setting)
|
||||
session.flush()
|
||||
template_setting_id = new_template_setting.id
|
||||
|
||||
if "permission" in md_data:
|
||||
permission = md_data.get("permission")
|
||||
if permission:
|
||||
new_permission_setting = MarkdownPermissionSetting(permission=permission)
|
||||
session.add(new_permission_setting)
|
||||
session.flush()
|
||||
permission_setting_id = new_permission_setting.id
|
||||
|
||||
if template_setting_id or permission_setting_id:
|
||||
md_setting = MarkdownSetting(
|
||||
template_setting_id=template_setting_id,
|
||||
permission_setting_id=permission_setting_id
|
||||
)
|
||||
session.add(md_setting)
|
||||
session.flush()
|
||||
setting_id = md_setting.id
|
||||
|
||||
existing_md = session.query(Markdown).filter_by(path_id=new_path_id, title=md_title).first()
|
||||
|
||||
if existing_md:
|
||||
existing_md.content = content
|
||||
existing_md.created_at = created_at
|
||||
existing_md.order = order
|
||||
existing_md.shortcut = shortcut
|
||||
existing_md.setting_id = setting_id
|
||||
session.commit()
|
||||
else:
|
||||
new_md = Markdown(
|
||||
title=md_title,
|
||||
content=content,
|
||||
path_id=new_path_id,
|
||||
created_at=created_at,
|
||||
order=order,
|
||||
shortcut=shortcut,
|
||||
setting_id=setting_id
|
||||
)
|
||||
session.add(new_md)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing markdown file {file_name}: {e}")
|
||||
|
||||
def restore_tree(dir_path, parent_id, session, path_mapping, webhook_mapping=None):
|
||||
if webhook_mapping is None:
|
||||
webhook_mapping = {}
|
||||
|
||||
dir_name = os.path.basename(dir_path)
|
||||
|
||||
if dir_name == "Root" or dir_name == "tree":
|
||||
new_path_id = 1
|
||||
path_mapping[dir_path] = new_path_id
|
||||
|
||||
for item in os.listdir(dir_path):
|
||||
item_path = os.path.join(dir_path, item)
|
||||
if os.path.isdir(item_path):
|
||||
restore_tree(item_path, 1, session, path_mapping, webhook_mapping)
|
||||
elif item.endswith(".json") and not item == ".json.meta":
|
||||
process_markdown_file(item_path, item, new_path_id, session)
|
||||
return
|
||||
existing_path = session.query(Path).filter_by(parent_id=parent_id, name=dir_name).first()
|
||||
if existing_path:
|
||||
new_path_id = existing_path.id
|
||||
else:
|
||||
order = ''
|
||||
setting_id = None
|
||||
|
||||
meta_file_path = os.path.join(dir_path, ".json.meta")
|
||||
if os.path.exists(meta_file_path):
|
||||
try:
|
||||
with open(meta_file_path, "r") as meta_file:
|
||||
path_meta = json.load(meta_file)
|
||||
order = path_meta.get("order", '')
|
||||
|
||||
webhook_setting_id = None
|
||||
if "webhook_ref" in path_meta and "webhook_setting" in path_meta:
|
||||
hook_url = path_meta["webhook_ref"].get("hook_url")
|
||||
|
||||
webhook_id = None
|
||||
existing_webhook = session.query(Webhook).filter_by(hook_url=hook_url).first()
|
||||
if existing_webhook:
|
||||
webhook_id = existing_webhook.id
|
||||
else:
|
||||
new_webhook = Webhook(hook_url=hook_url)
|
||||
session.add(new_webhook)
|
||||
session.flush()
|
||||
webhook_id = new_webhook.id
|
||||
|
||||
if webhook_id:
|
||||
webhook_setting_data = path_meta["webhook_setting"]
|
||||
new_webhook_setting = WebhookSetting(
|
||||
webhook_id=webhook_id,
|
||||
recursive=webhook_setting_data.get("recursive", False),
|
||||
additional_header=webhook_setting_data.get("additional_header"),
|
||||
enabled=webhook_setting_data.get("enabled", True),
|
||||
on_events=webhook_setting_data.get("on_events", 0)
|
||||
)
|
||||
session.add(new_webhook_setting)
|
||||
session.flush()
|
||||
webhook_setting_id = new_webhook_setting.id
|
||||
|
||||
template_setting_id = None
|
||||
if "template_ref" in path_meta:
|
||||
template_title = path_meta["template_ref"].get("title")
|
||||
|
||||
existing_template = session.query(PathTemplate).filter_by(title=template_title).first()
|
||||
if existing_template:
|
||||
template_setting_id = existing_template.id
|
||||
|
||||
if webhook_setting_id or template_setting_id:
|
||||
path_setting = PathSetting(
|
||||
webhook_setting_id=webhook_setting_id,
|
||||
template_setting_id=template_setting_id
|
||||
)
|
||||
session.add(path_setting)
|
||||
session.flush()
|
||||
setting_id = path_setting.id
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing path metadata: {e}")
|
||||
|
||||
new_path = Path(name=dir_name, parent_id=parent_id, order=order, setting_id=setting_id)
|
||||
session.add(new_path)
|
||||
session.flush()
|
||||
new_path_id = new_path.id
|
||||
|
||||
path_mapping[dir_path] = new_path_id
|
||||
|
||||
for file in os.listdir(dir_path):
|
||||
file_path = os.path.join(dir_path, file)
|
||||
if file.endswith(".json") and not file == ".json.meta":
|
||||
process_markdown_file(file_path, file, new_path_id, session)
|
||||
|
||||
for item in os.listdir(dir_path):
|
||||
item_path = os.path.join(dir_path, item)
|
||||
if os.path.isdir(item_path):
|
||||
restore_tree(item_path, new_path_id, session, path_mapping, webhook_mapping)
|
||||
|
||||
@@ -12,17 +12,48 @@ def is_valid_rate_limit(limit):
|
||||
@require_auth(roles=['admin'])
|
||||
@etag_response
|
||||
def limits():
|
||||
"""
|
||||
Get all rate limits.
|
||||
|
||||
This endpoint retrieves a list of all rate limits configured in the system.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Returns:
|
||||
A JSON object containing all rate limits, with keys in the format "endpoint : method".
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
"""
|
||||
return jsonify(rate_limits), 200
|
||||
|
||||
@config_bp.route('/limits', methods=['PUT'])
|
||||
@require_auth(roles=['admin'])
|
||||
def update_limits():
|
||||
"""
|
||||
Update a rate limit.
|
||||
|
||||
This endpoint updates the rate limit for a specific endpoint and method.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- endpoint (str): The endpoint path to update
|
||||
- method (str): The HTTP method to update
|
||||
- new_limit (str): The new rate limit value (format: "number per second/minute/hour/day")
|
||||
|
||||
Returns:
|
||||
A JSON object with a success message.
|
||||
|
||||
Response Codes:
|
||||
- 200: Updated successfully
|
||||
- 400: Bad request (missing required fields or invalid rate limit format)
|
||||
- 404: Endpoint not found
|
||||
"""
|
||||
data = request.json
|
||||
if not data or 'endpoint' not in data or 'method' not in data or 'new_limit' not in data:
|
||||
return jsonify({'error': 'Bad request'}), 400
|
||||
key = f"{data['endpoint']} : {data['method']}"
|
||||
if key not in rate_limits:
|
||||
return jsonify({'error': 'endpoint not fount'}), 404
|
||||
return jsonify({'error': 'endpoint not found'}), 404
|
||||
if is_valid_rate_limit(data['new_limit']):
|
||||
rate_limits[key] = data['new_limit']
|
||||
return jsonify({"message": "updated"}), 200
|
||||
|
||||
@@ -41,3 +41,4 @@ def create_log():
|
||||
extra = data.get('extra', None)
|
||||
log_entry = Log(level=level, message=message, application=application, extra=extra)
|
||||
insert_log(log_entry)
|
||||
return jsonify({"message": "log created"}), 201
|
||||
|
||||
295
api/markdown.py
295
api/markdown.py
@@ -1,12 +1,16 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from sqlalchemy import or_
|
||||
import api
|
||||
from api import require_auth, etag_response
|
||||
from api import limiter
|
||||
from api import require_auth, etag_response, verify_token, is_user_admin
|
||||
from contexts.RequestContext import RequestContext
|
||||
from db import get_db
|
||||
from db.models.Markdown import Markdown
|
||||
from db.models.MarkdownSetting import MarkdownSetting
|
||||
from db.models.MarkdownPermissionSetting import MarkdownPermissionSetting
|
||||
from events import markdown_created, markdown_updated, markdown_deleted
|
||||
import api
|
||||
import env_provider
|
||||
import logging
|
||||
from api import limiter
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
markdown_bp = Blueprint('markdown', __name__, url_prefix='/api/markdown')
|
||||
@@ -15,6 +19,17 @@ markdown_bp = Blueprint('markdown', __name__, url_prefix='/api/markdown')
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@etag_response
|
||||
def get_markdowns():
|
||||
"""
|
||||
Get all markdown documents.
|
||||
|
||||
This endpoint retrieves a list of all markdown documents in the system.
|
||||
|
||||
Returns:
|
||||
A JSON array containing all markdown documents.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
"""
|
||||
with get_db() as session:
|
||||
mds = session.query(Markdown).all()
|
||||
return jsonify([md.to_dict() for md in mds]), 200
|
||||
@@ -23,8 +38,21 @@ def get_markdowns():
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@etag_response
|
||||
def get_home():
|
||||
"""
|
||||
Get the home markdown document.
|
||||
|
||||
This endpoint retrieves the index markdown document from the root path (path_id=1).
|
||||
This is typically the main landing page content.
|
||||
|
||||
Returns:
|
||||
A JSON object containing the home markdown document.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
- 204: No content (home markdown not found)
|
||||
"""
|
||||
with get_db() as session:
|
||||
markdown = session.query(Markdown).filter(Markdown.path_id == 1).filter(Markdown.title == "index").first()
|
||||
markdown = session.query(Markdown).filter(Markdown.path_id == 1, Markdown.title == "index").first()
|
||||
if markdown is None:
|
||||
return jsonify({}), 204
|
||||
return jsonify(markdown.to_dict()), 200
|
||||
@@ -33,6 +61,20 @@ def get_home():
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@etag_response
|
||||
def get_markdowns_by_path(path_id):
|
||||
"""
|
||||
Get all markdown documents in a specific path.
|
||||
|
||||
This endpoint retrieves a list of all markdown documents that belong to the specified path.
|
||||
|
||||
Request:
|
||||
- path_id (int): The ID of the path to get markdowns from
|
||||
|
||||
Returns:
|
||||
A JSON array containing all markdown documents in the specified path.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
"""
|
||||
with get_db() as session:
|
||||
markdowns = session.query(Markdown).filter(Markdown.path_id == path_id).all()
|
||||
return jsonify([md.to_dict() for md in markdowns]), 200
|
||||
@@ -41,6 +83,22 @@ def get_markdowns_by_path(path_id):
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@etag_response
|
||||
def get_index(path_id):
|
||||
"""
|
||||
Get the index markdown document for a specific path.
|
||||
|
||||
This endpoint retrieves the index markdown document from the specified path.
|
||||
The index document typically serves as the main content or landing page for a path.
|
||||
|
||||
Request:
|
||||
- path_id (int): The ID of the path to get the index markdown from
|
||||
|
||||
Returns:
|
||||
A JSON object containing the index markdown document.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
- 204: No content (index markdown not found)
|
||||
"""
|
||||
with get_db() as session:
|
||||
markdown = session.query(Markdown).filter(Markdown.path_id == path_id).filter(Markdown.title == "index").first()
|
||||
if markdown is None:
|
||||
@@ -53,24 +111,77 @@ def get_index(path_id):
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@etag_response
|
||||
def get_markdown(markdown_id):
|
||||
"""
|
||||
Get a specific markdown document by ID.
|
||||
|
||||
This endpoint retrieves a markdown document by its ID. It includes permission checks
|
||||
based on the user's role and the markdown's permission settings.
|
||||
|
||||
Request:
|
||||
- markdown_id (int): The ID of the markdown document to retrieve
|
||||
|
||||
Returns:
|
||||
A JSON object containing the markdown document.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
- 203: Permission denied (for protected markdowns when user is not an admin)
|
||||
- 403: Permission denied (for private markdowns when user is not an admin)
|
||||
- 404: Markdown not found
|
||||
"""
|
||||
is_admin = is_user_admin()
|
||||
|
||||
with get_db() as session:
|
||||
markdown = session.query(Markdown).get(markdown_id)
|
||||
if markdown is None:
|
||||
return jsonify({"error": "file not found"}), 404
|
||||
|
||||
if not is_admin and markdown.setting_id is not None:
|
||||
setting = session.query(MarkdownSetting).get(markdown.setting_id)
|
||||
if setting and setting.permission_setting_id:
|
||||
permission_setting = session.query(MarkdownPermissionSetting).get(setting.permission_setting_id)
|
||||
if permission_setting:
|
||||
if permission_setting.permission == 'private':
|
||||
return jsonify({"msg": "permission denied"}), 403
|
||||
elif permission_setting.permission == 'protected':
|
||||
return jsonify({"msg": "permission denied"}), 203
|
||||
|
||||
return jsonify(markdown.to_dict()), 200
|
||||
|
||||
@markdown_bp.route('/', methods=['POST'])
|
||||
@require_auth(roles=['admin', 'creator'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def create_markdown():
|
||||
"""
|
||||
Create a new markdown document.
|
||||
|
||||
This endpoint creates a new markdown document with the provided data.
|
||||
It requires authentication with either 'admin' or 'creator' role.
|
||||
|
||||
Request:
|
||||
- title (str): The title of the markdown document
|
||||
- content (str): The content of the markdown document
|
||||
- path_id (int): The ID of the path where the markdown will be created
|
||||
- shortcut (str, optional): A shortcut identifier for the markdown
|
||||
- setting_id (int, optional): The ID of the markdown settings
|
||||
|
||||
Returns:
|
||||
A JSON object containing the created markdown document.
|
||||
|
||||
Response Codes:
|
||||
- 201: Created successfully
|
||||
- 400: Bad request (missing required fields or duplicate shortcut)
|
||||
- 500: Server error
|
||||
"""
|
||||
data = request.json
|
||||
title = data.get('title')
|
||||
content = data.get('content')
|
||||
path_id = data.get('path_id')
|
||||
shortcut = data.get('shortcut', "")
|
||||
setting_id = data.get('setting_id', None)
|
||||
if not title or not content:
|
||||
return jsonify({"error": "missing required fields"}), 400
|
||||
new_markdown = Markdown(title=title, content=content, path_id=path_id, shortcut=shortcut)
|
||||
new_markdown = Markdown(title=title, content=content, path_id=path_id, shortcut=shortcut, setting_id=setting_id)
|
||||
with get_db() as session:
|
||||
try:
|
||||
if shortcut != "":
|
||||
@@ -79,6 +190,7 @@ def create_markdown():
|
||||
return jsonify({"error": "duplicate shortcut"}), 400
|
||||
session.add(new_markdown)
|
||||
session.commit()
|
||||
markdown_created.send(None, payload=new_markdown.to_dict())
|
||||
return jsonify(new_markdown.to_dict()), 201
|
||||
except Exception as e:
|
||||
logger.error(f"failed to create markdown: {e}")
|
||||
@@ -90,6 +202,31 @@ def create_markdown():
|
||||
@require_auth(roles=['admin', 'creator'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def update_markdown(markdown_id):
|
||||
"""
|
||||
Update a markdown document.
|
||||
|
||||
This endpoint updates an existing markdown document with the provided data.
|
||||
It requires authentication with either 'admin' or 'creator' role.
|
||||
|
||||
- PUT: Replaces the entire markdown document
|
||||
- PATCH: Updates only the specified fields
|
||||
|
||||
Request:
|
||||
- markdown_id (int): The ID of the markdown document to update
|
||||
- title (str, optional for PATCH): The new title for the markdown
|
||||
- content (str, optional for PATCH): The new content for the markdown
|
||||
- path_id (int, optional for PATCH): The new path ID for the markdown
|
||||
- shortcut (str, optional): A shortcut identifier for the markdown
|
||||
- setting_id (int, optional): The ID of the markdown settings
|
||||
|
||||
Returns:
|
||||
A JSON object containing the updated markdown document.
|
||||
|
||||
Response Codes:
|
||||
- 200: Updated successfully
|
||||
- 400: Bad request (duplicate shortcut)
|
||||
- 404: Markdown not found
|
||||
"""
|
||||
with get_db() as session:
|
||||
markdown = session.query(Markdown).get(markdown_id)
|
||||
if markdown is None:
|
||||
@@ -108,6 +245,7 @@ def update_markdown(markdown_id):
|
||||
markdown.content = data.get('content')
|
||||
markdown.path_id = data.get('path_id')
|
||||
markdown.shortcut = data.get('shortcut', '')
|
||||
markdown.setting_id = data.get('setting_id', None)
|
||||
elif request.method == "PATCH":
|
||||
if 'title' in data:
|
||||
markdown.title = data.get('title')
|
||||
@@ -117,28 +255,110 @@ def update_markdown(markdown_id):
|
||||
markdown.path_id = data.get('path_id')
|
||||
if 'shortcut' in data:
|
||||
markdown.shortcut = data.get('shortcut')
|
||||
if 'setting_id' in data:
|
||||
markdown.setting_id = data.get('setting_id')
|
||||
session.commit()
|
||||
markdown_updated.send(None, payload=markdown.to_dict())
|
||||
return jsonify(markdown.to_dict()), 200
|
||||
|
||||
@markdown_bp.route('/<int:markdown_id>', methods=['DELETE'])
|
||||
@require_auth(roles=['admin'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def delete_markdown(markdown_id):
|
||||
"""
|
||||
Delete a markdown document.
|
||||
|
||||
This endpoint deletes an existing markdown document and cascades the deletion
|
||||
to related settings to avoid foreign key conflicts.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- markdown_id (int): The ID of the markdown document to delete
|
||||
|
||||
Returns:
|
||||
A JSON object containing the deleted markdown document.
|
||||
|
||||
Response Codes:
|
||||
- 200: Deleted successfully
|
||||
- 404: Markdown not found
|
||||
- 500: Server error during cascade deletion
|
||||
"""
|
||||
with get_db() as session:
|
||||
markdown = session.query(Markdown).get(markdown_id)
|
||||
if markdown is None:
|
||||
logger.error(f"failed to delete markdown: {markdown_id}")
|
||||
try:
|
||||
markdown = session.get(Markdown, markdown_id)
|
||||
if markdown is None:
|
||||
logger.error(f"failed to delete markdown: {markdown_id}")
|
||||
errno = RequestContext.get_error_id()
|
||||
return jsonify({"error": f"file not found - {errno}"}), 404
|
||||
|
||||
md = markdown.to_dict()
|
||||
|
||||
|
||||
if markdown.setting_id:
|
||||
markdown_setting = session.query(MarkdownSetting).get(markdown.setting_id)
|
||||
if markdown_setting:
|
||||
template_setting_id = markdown_setting.template_setting_id
|
||||
permission_setting_id = markdown_setting.permission_setting_id
|
||||
|
||||
markdown_setting.template_setting_id = None
|
||||
markdown_setting.permission_setting_id = None
|
||||
session.flush()
|
||||
|
||||
if template_setting_id:
|
||||
from db.models.MarkdownTemplateSetting import MarkdownTemplateSetting
|
||||
template_setting = session.query(MarkdownTemplateSetting).get(template_setting_id)
|
||||
if template_setting:
|
||||
session.delete(template_setting)
|
||||
|
||||
if permission_setting_id:
|
||||
permission_setting = session.query(MarkdownPermissionSetting).get(permission_setting_id)
|
||||
if permission_setting:
|
||||
session.delete(permission_setting)
|
||||
|
||||
session.delete(markdown_setting)
|
||||
|
||||
# Send webhook event before committing the transaction
|
||||
# This ensures webhook handlers can still access related data
|
||||
markdown_deleted.send(None, payload=md)
|
||||
|
||||
session.delete(markdown)
|
||||
session.commit()
|
||||
|
||||
logger.info(f"Successfully deleted markdown {markdown_id} with cascade deletion")
|
||||
return jsonify(md), 200
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"Failed to delete markdown {markdown_id}: {e}")
|
||||
logger.error(f"Exception type: {type(e).__name__}")
|
||||
logger.error(f"Full traceback:\n{traceback.format_exc()}")
|
||||
errno = RequestContext.get_error_id()
|
||||
return jsonify({"error": f"file not found - {errno}"}), 404
|
||||
session.delete(markdown)
|
||||
session.commit()
|
||||
return jsonify({"message": "deleted"}), 200
|
||||
session.rollback()
|
||||
return jsonify({"error": f"delete failed - {errno}"}), 500
|
||||
|
||||
|
||||
@markdown_bp.route('/move_forward/<int:markdown_id>', methods=['PATCH'])
|
||||
@require_auth(roles=['admin'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def move_forward(markdown_id):
|
||||
"""
|
||||
Move a markdown document forward in display order.
|
||||
|
||||
This endpoint moves a markdown document one position forward in the display order by swapping its order value
|
||||
with the previous markdown in the same path. This affects how markdowns are displayed in the UI.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- markdown_id (int): The ID of the markdown document to move forward
|
||||
|
||||
Returns:
|
||||
A JSON object containing the updated markdown document.
|
||||
|
||||
Response Codes:
|
||||
- 200: Moved successfully
|
||||
- 400: Bad request (already at the first position)
|
||||
- 404: Markdown not found
|
||||
"""
|
||||
with get_db() as session:
|
||||
markdown = session.query(Markdown).get(markdown_id)
|
||||
if not markdown:
|
||||
@@ -150,6 +370,7 @@ def move_forward(markdown_id):
|
||||
previous_markdown = siblings[current_index - 1]
|
||||
markdown.order, previous_markdown.order = previous_markdown.order, markdown.order
|
||||
session.commit()
|
||||
markdown_updated.send(None, payload=markdown.to_dict())
|
||||
return jsonify(markdown.to_dict()), 200
|
||||
|
||||
|
||||
@@ -157,8 +378,26 @@ def move_forward(markdown_id):
|
||||
@require_auth(roles=['admin'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def move_backward(markdown_id):
|
||||
"""
|
||||
Move a markdown document backward in display order.
|
||||
|
||||
This endpoint moves a markdown document one position backward in the display order by swapping its order value
|
||||
with the next markdown in the same path. This affects how markdowns are displayed in the UI.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- markdown_id (int): The ID of the markdown document to move backward
|
||||
|
||||
Returns:
|
||||
A JSON object containing the updated markdown document.
|
||||
|
||||
Response Codes:
|
||||
- 200: Moved successfully
|
||||
- 400: Bad request (already at the last position)
|
||||
- 404: Markdown not found
|
||||
"""
|
||||
with get_db() as session:
|
||||
markdown = session.query(Markdown).get(markdown_id)
|
||||
markdown = session.get(Markdown, markdown_id)
|
||||
if not markdown:
|
||||
return jsonify({"error": "file not found"}), 404
|
||||
siblings = session.query(Markdown).filter(Markdown.path_id == markdown.path_id).order_by(Markdown.order).all()
|
||||
@@ -167,13 +406,29 @@ def move_backward(markdown_id):
|
||||
return jsonify({"error": "already at the last position"}), 400
|
||||
|
||||
next_markdown = siblings[current_index + 1]
|
||||
markdown.order, next_markdown.order = next_markdown.order, next_markdown.order
|
||||
markdown.order, next_markdown.order = next_markdown.order, markdown.order
|
||||
session.commit()
|
||||
markdown_updated.send(None, payload=markdown.to_dict())
|
||||
return jsonify(markdown.to_dict()), 200
|
||||
|
||||
@markdown_bp.route('/search/<string:keyword>', methods=['GET'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def search_markdowns(keyword):
|
||||
"""
|
||||
Search for markdown documents.
|
||||
|
||||
This endpoint searches for markdown documents that contain the specified keyword
|
||||
in either their title or content.
|
||||
|
||||
Request:
|
||||
- keyword (str): The search term to look for in markdown titles and content
|
||||
|
||||
Returns:
|
||||
A JSON array containing all markdown documents that match the search criteria.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
"""
|
||||
with get_db() as session:
|
||||
res = session.query(Markdown).filter(
|
||||
or_(Markdown.title.like(keyword), Markdown.content.like(keyword))
|
||||
@@ -183,6 +438,18 @@ def search_markdowns(keyword):
|
||||
@markdown_bp.route('/links', methods=['GET'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def get_links():
|
||||
"""
|
||||
Get all markdown shortcut links.
|
||||
|
||||
This endpoint retrieves a list of all markdown documents that have a shortcut defined,
|
||||
formatted as links in the format "[shortcut]: id".
|
||||
|
||||
Returns:
|
||||
A JSON array containing formatted links for all markdown documents with shortcuts.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
"""
|
||||
with get_db() as session:
|
||||
mds = [md.to_dict() for md in session.query(Markdown).filter(Markdown.shortcut != "").all()]
|
||||
links = [f"[{md['shortcut']}]: {md['id']}" for md in mds]
|
||||
|
||||
171
api/path.py
171
api/path.py
@@ -7,6 +7,9 @@ from db.models.Markdown import Markdown
|
||||
from db.models.Path import Path
|
||||
from api import limiter
|
||||
import logging
|
||||
|
||||
from events import path_created, path_updated, path_deleted
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
path_bp = Blueprint('path', __name__, url_prefix='/api/path')
|
||||
@@ -15,6 +18,17 @@ path_bp = Blueprint('path', __name__, url_prefix='/api/path')
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@etag_response
|
||||
def get_root_paths():
|
||||
"""
|
||||
Get all paths under the root path.
|
||||
|
||||
This endpoint retrieves a list of all paths that are direct children of the root path (parent_id=1).
|
||||
|
||||
Returns:
|
||||
A JSON array containing all root paths.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
"""
|
||||
with get_db() as session:
|
||||
paths = session.query(Path).filter(Path.parent_id == 1)
|
||||
return jsonify([pth.to_dict() for pth in paths]), 200
|
||||
@@ -23,6 +37,21 @@ def get_root_paths():
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@etag_response
|
||||
def get_path(path_id):
|
||||
"""
|
||||
Get a specific path by ID.
|
||||
|
||||
This endpoint retrieves a path by its ID.
|
||||
|
||||
Request:
|
||||
- path_id (int): The ID of the path to retrieve
|
||||
|
||||
Returns:
|
||||
A JSON object containing the path.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
- 404: Path not found
|
||||
"""
|
||||
with get_db() as session:
|
||||
path = session.query(Path).get(path_id)
|
||||
if path is None:
|
||||
@@ -33,6 +62,20 @@ def get_path(path_id):
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@etag_response
|
||||
def get_path_by_parent(parent_id):
|
||||
"""
|
||||
Get all paths under a specific parent path.
|
||||
|
||||
This endpoint retrieves a list of all paths that are direct children of the specified parent path.
|
||||
|
||||
Request:
|
||||
- parent_id (int): The ID of the parent path
|
||||
|
||||
Returns:
|
||||
A JSON array containing all child paths of the specified parent.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
"""
|
||||
with get_db() as session:
|
||||
paths = session.query(Path).filter(Path.parent_id == parent_id).all()
|
||||
return jsonify([pth.to_dict() for pth in paths]), 200
|
||||
@@ -41,6 +84,25 @@ def get_path_by_parent(parent_id):
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@require_auth(roles=['admin', 'creator'])
|
||||
def create_path():
|
||||
"""
|
||||
Create a new path.
|
||||
|
||||
This endpoint creates a new path with the provided name and parent ID.
|
||||
It requires authentication with either 'admin' or 'creator' role.
|
||||
|
||||
Request:
|
||||
- name (str): The name of the path
|
||||
- parent_id (int): The ID of the parent path
|
||||
|
||||
Returns:
|
||||
A JSON object containing the created path.
|
||||
|
||||
Response Codes:
|
||||
- 201: Created successfully
|
||||
- 400: Bad request (missing required fields)
|
||||
- 404: Parent path not found
|
||||
- 409: Conflict (path already exists under the parent)
|
||||
"""
|
||||
data = request.json
|
||||
if not data or 'name' not in data or 'parent_id' not in data:
|
||||
return jsonify({"error": "bad request"}), 400
|
||||
@@ -52,12 +114,33 @@ def create_path():
|
||||
new_path = Path(name=data['name'], parent_id=data['parent_id'])
|
||||
session.add(new_path)
|
||||
session.commit()
|
||||
path_created.send(None, payload=new_path.to_dict())
|
||||
return jsonify(new_path.to_dict()), 201
|
||||
|
||||
@path_bp.route('/<int:path_id>', methods=['PUT'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@require_auth(roles=['admin'])
|
||||
def update_path(path_id):
|
||||
"""
|
||||
Update a path.
|
||||
|
||||
This endpoint updates an existing path with the provided name and parent ID.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- path_id (int): The ID of the path to update
|
||||
- name (str): The new name for the path
|
||||
- parent_id (int): The new parent ID for the path
|
||||
|
||||
Returns:
|
||||
A JSON object containing the updated path.
|
||||
|
||||
Response Codes:
|
||||
- 200: Updated successfully
|
||||
- 400: Bad request (missing required fields)
|
||||
- 404: Path not found
|
||||
- 409: Conflict (path already exists under the parent)
|
||||
"""
|
||||
data = request.json
|
||||
if not data or 'name' not in data or 'parent_id' not in data:
|
||||
return jsonify({"error": "bad request"}), 400
|
||||
@@ -70,14 +153,37 @@ def update_path(path_id):
|
||||
path.name = data['name']
|
||||
path.parent_id = data['parent_id']
|
||||
session.commit()
|
||||
path_updated.send(None, payload=path.to_dict())
|
||||
return jsonify(path.to_dict()), 200
|
||||
|
||||
@path_bp.route('/<int:path_id>', methods=['PATCH'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@require_auth(roles=['admin'])
|
||||
def patch_path(path_id):
|
||||
"""
|
||||
Partially update a path.
|
||||
|
||||
This endpoint partially updates an existing path with the provided data.
|
||||
Unlike the PUT method, this endpoint only updates the fields that are provided in the request.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- path_id (int): The ID of the path to update
|
||||
- name (str, optional): The new name for the path
|
||||
- parent_id (int, optional): The new parent ID for the path
|
||||
- setting_id (int, optional): The new setting ID for the path
|
||||
|
||||
Returns:
|
||||
A JSON object containing the updated path.
|
||||
|
||||
Response Codes:
|
||||
- 200: Updated successfully
|
||||
- 400: Bad request (empty data)
|
||||
- 404: Path not found
|
||||
- 409: Conflict (path already exists under the parent)
|
||||
"""
|
||||
data = request.json
|
||||
if not data or 'name' not in data and 'parent_id' not in data:
|
||||
if not data:
|
||||
return jsonify({"error": "bad request"}), 400
|
||||
with get_db() as session:
|
||||
path = session.query(Path).get(path_id)
|
||||
@@ -85,12 +191,15 @@ def patch_path(path_id):
|
||||
return jsonify({"error": "path not found"}), 404
|
||||
updated_name =data.get('name', path.name)
|
||||
updated_parent_id = data.get('parent_id', path.parent_id)
|
||||
updated_setting_id = data.get('setting_id', path.setting_id)
|
||||
|
||||
if session.query(Path).filter_by(name=updated_name, parent_id=updated_parent_id).first():
|
||||
if session.query(Path).filter(Path.name==updated_name, Path.parent_id==updated_parent_id, Path.id != path_id).first():
|
||||
return jsonify({"error": "Path already exists under the parent"}), 409
|
||||
path.name = updated_name
|
||||
path.parent_id = updated_parent_id
|
||||
path.setting_id = updated_setting_id
|
||||
session.commit()
|
||||
path_updated.send(None, payload=path.to_dict())
|
||||
return jsonify(path.to_dict()), 200
|
||||
|
||||
|
||||
@@ -98,6 +207,23 @@ def patch_path(path_id):
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@require_auth(roles=['admin'])
|
||||
def delete_path(path_id):
|
||||
"""
|
||||
Delete a path.
|
||||
|
||||
This endpoint deletes an existing path. The path must be empty (no child paths or markdowns)
|
||||
to be deleted. It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- path_id (int): The ID of the path to delete
|
||||
|
||||
Returns:
|
||||
A JSON object with a success message.
|
||||
|
||||
Response Codes:
|
||||
- 200: Deleted successfully
|
||||
- 404: Path not found
|
||||
- 409: Conflict (path contains child paths or markdowns)
|
||||
"""
|
||||
with get_db() as session:
|
||||
path = session.query(Path).get(path_id)
|
||||
if not path:
|
||||
@@ -106,8 +232,10 @@ def delete_path(path_id):
|
||||
return jsonify({"error": "can not delete non empty path"}), 409
|
||||
if session.query(Markdown).filter_by(path_id=path_id).first():
|
||||
return jsonify({"error": "can not delete non empty path"}), 409
|
||||
pth = path.to_dict()
|
||||
session.delete(path)
|
||||
session.commit()
|
||||
path_deleted.send(None, payload=pth)
|
||||
return jsonify({"message": "path deleted"}), 200
|
||||
|
||||
|
||||
@@ -115,6 +243,24 @@ def delete_path(path_id):
|
||||
@require_auth(roles=['admin'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def move_forward(path_id):
|
||||
"""
|
||||
Move a path forward in display order.
|
||||
|
||||
This endpoint moves a path one position forward in the display order by swapping its order value
|
||||
with the previous path in the same parent. This affects how paths are displayed in the UI.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- path_id (int): The ID of the path to move forward
|
||||
|
||||
Returns:
|
||||
A JSON object containing the updated path.
|
||||
|
||||
Response Codes:
|
||||
- 200: Moved successfully
|
||||
- 400: Bad request (already at the first position)
|
||||
- 404: Path not found
|
||||
"""
|
||||
with get_db() as session:
|
||||
path = session.query(Path).get(path_id)
|
||||
if not path:
|
||||
@@ -126,6 +272,7 @@ def move_forward(path_id):
|
||||
previous_path = siblings[current_index - 1]
|
||||
path.order, previous_path.order = previous_path.order, path.order
|
||||
session.commit()
|
||||
path_updated.send(None, payload=path.to_dict())
|
||||
return jsonify(path.to_dict()), 200
|
||||
|
||||
|
||||
@@ -133,6 +280,24 @@ def move_forward(path_id):
|
||||
@require_auth(roles=['admin'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def move_backward(path_id):
|
||||
"""
|
||||
Move a path backward in display order.
|
||||
|
||||
This endpoint moves a path one position backward in the display order by swapping its order value
|
||||
with the next path in the same parent. This affects how paths are displayed in the UI.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- path_id (int): The ID of the path to move backward
|
||||
|
||||
Returns:
|
||||
A JSON object containing the updated path.
|
||||
|
||||
Response Codes:
|
||||
- 200: Moved successfully
|
||||
- 400: Bad request (already at the last position)
|
||||
- 404: Path not found
|
||||
"""
|
||||
with get_db() as session:
|
||||
path = session.query(Path).get(path_id)
|
||||
if not path:
|
||||
@@ -145,5 +310,5 @@ def move_backward(path_id):
|
||||
next_path = siblings[current_index + 1]
|
||||
path.order, next_path.order = next_path.order, path.order
|
||||
session.commit()
|
||||
path_updated.send(None, payload=path.to_dict())
|
||||
return jsonify(path.to_dict()), 200
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#api/resource.py
|
||||
import api
|
||||
from flask import Blueprint, jsonify, request
|
||||
from contexts.RequestContext import RequestContext
|
||||
|
||||
8
api/setting/__init__.py
Normal file
8
api/setting/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import importlib
|
||||
import pkgutil
|
||||
|
||||
from flask import Blueprint
|
||||
setting_bp = Blueprint('setting', __name__, url_prefix='/api/setting')
|
||||
|
||||
for loader, module_name, is_pkg in pkgutil.walk_packages(__path__, prefix=__name__ + "."):
|
||||
importlib.import_module(module_name)
|
||||
133
api/setting/markdown/__init__.py
Normal file
133
api/setting/markdown/__init__.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from flask import jsonify, request, Blueprint
|
||||
|
||||
import api
|
||||
from api import limiter, etag_response, require_auth
|
||||
from db import get_db
|
||||
from db.models.MarkdownSetting import MarkdownSetting
|
||||
|
||||
markdown_setting_bp = Blueprint('markdown_setting', __name__, url_prefix='/api/setting/markdown')
|
||||
@markdown_setting_bp.route('/<int:setting_id>', methods=['GET'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@etag_response
|
||||
def get_markdown_path(setting_id):
|
||||
"""
|
||||
Get a specific markdown setting by ID.
|
||||
|
||||
This endpoint retrieves a markdown setting by its ID.
|
||||
|
||||
Request:
|
||||
- setting_id (int): The ID of the markdown setting to retrieve
|
||||
|
||||
Returns:
|
||||
A JSON object containing the markdown setting.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
- 204: No content (setting not found)
|
||||
"""
|
||||
with get_db() as session:
|
||||
setting = session.query(MarkdownSetting).get(setting_id)
|
||||
if setting is None:
|
||||
return jsonify({}), 204
|
||||
return jsonify(setting.to_dict()), 200
|
||||
|
||||
|
||||
@markdown_setting_bp.route('/', methods=['POST'])
|
||||
@require_auth(roles=['admin'])
|
||||
def create_markdown_setting():
|
||||
"""
|
||||
Create a new markdown setting.
|
||||
|
||||
This endpoint creates a new markdown setting with the provided template and permission settings.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- template_setting_id (int, optional): The ID of the template setting to associate
|
||||
- permission_setting_id (int, optional): The ID of the permission setting to associate
|
||||
|
||||
Returns:
|
||||
A JSON object containing the created markdown setting.
|
||||
|
||||
Response Codes:
|
||||
- 200: Created successfully
|
||||
- 500: Server error
|
||||
"""
|
||||
data = request.json
|
||||
template_setting_id = data.get('template_setting_id')
|
||||
permission_setting_id = data.get('permission_setting_id')
|
||||
setting = MarkdownSetting(
|
||||
template_setting_id=template_setting_id,
|
||||
permission_setting_id=permission_setting_id
|
||||
)
|
||||
try:
|
||||
with get_db() as session:
|
||||
session.add(setting)
|
||||
session.commit()
|
||||
return jsonify(setting.to_dict()), 200
|
||||
except Exception:
|
||||
return jsonify({"error": "failed to create setting"}), 500
|
||||
|
||||
@markdown_setting_bp.route('/<int:setting_id>', methods=['PUT', 'PATCH'])
|
||||
@require_auth(roles=['admin'])
|
||||
def update_markdown_setting(setting_id):
|
||||
"""
|
||||
Update a markdown setting.
|
||||
|
||||
This endpoint updates an existing markdown setting with the provided template and permission settings.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- setting_id (int): The ID of the markdown setting to update
|
||||
- template_setting_id (int, optional): The new template setting ID
|
||||
- permission_setting_id (int, optional): The new permission setting ID
|
||||
|
||||
Returns:
|
||||
A JSON object containing the updated markdown setting.
|
||||
|
||||
Response Codes:
|
||||
- 200: Updated successfully
|
||||
- 400: Bad request (setting not found)
|
||||
- 500: Server error
|
||||
"""
|
||||
data = request.json
|
||||
try:
|
||||
with get_db() as session:
|
||||
setting = session.query(MarkdownSetting).get(setting_id)
|
||||
if setting is None:
|
||||
return jsonify({"error": "setting not exists"}), 400
|
||||
template_setting_id = data.get('template_setting_id', setting.template_setting_id)
|
||||
permission_setting_id = data.get('permission_setting_id', setting.permission_setting_id)
|
||||
setting.template_setting_id = template_setting_id
|
||||
setting.permission_setting_id = permission_setting_id
|
||||
session.commit()
|
||||
return jsonify(setting.to_dict()), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": "failed to update setting"}), 500
|
||||
|
||||
|
||||
@markdown_setting_bp.route('/<int:setting_id>', methods=['DELETE'])
|
||||
@require_auth(roles=['admin'])
|
||||
def delete_markdown_setting(setting_id):
|
||||
"""
|
||||
Delete a markdown setting.
|
||||
|
||||
This endpoint deletes an existing markdown setting.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- setting_id (int): The ID of the markdown setting to delete
|
||||
|
||||
Returns:
|
||||
A JSON object with a success message.
|
||||
|
||||
Response Codes:
|
||||
- 200: Deleted successfully
|
||||
- 400: Bad request (setting not found)
|
||||
"""
|
||||
with get_db() as session:
|
||||
setting = session.query(MarkdownSetting).get(setting_id)
|
||||
if setting is None:
|
||||
return jsonify({"error": "setting not exists"}), 400
|
||||
session.delete(setting)
|
||||
session.commit()
|
||||
return jsonify({"message": "deleted"}), 200
|
||||
120
api/setting/markdown/permission.py
Normal file
120
api/setting/markdown/permission.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from flask import jsonify, request, Blueprint
|
||||
import api
|
||||
from api import etag_response, limiter, require_auth
|
||||
from db import get_db
|
||||
from db.models.MarkdownPermissionSetting import MarkdownPermissionSetting
|
||||
|
||||
markdown_permission_setting_bp = Blueprint('markdown_permission_setting', __name__, url_prefix='/api/setting/markdown/permission')
|
||||
@markdown_permission_setting_bp.route('/<int:setting_id>', methods=['GET'])
|
||||
@etag_response
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def get_permission_setting(setting_id):
|
||||
"""
|
||||
Get a specific markdown permission setting by ID.
|
||||
|
||||
This endpoint retrieves a markdown permission setting by its ID.
|
||||
|
||||
Request:
|
||||
- setting_id (int): The ID of the permission setting to retrieve
|
||||
|
||||
Returns:
|
||||
A JSON object containing the permission setting.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
- 204: No content (setting not found)
|
||||
"""
|
||||
with get_db() as session:
|
||||
setting = session.query(MarkdownPermissionSetting).get(setting_id)
|
||||
if not setting:
|
||||
return jsonify({}), 204
|
||||
return jsonify(setting.to_dict()), 200
|
||||
|
||||
|
||||
@markdown_permission_setting_bp.route('/', methods=['POST'])
|
||||
@require_auth(roles=['admin'])
|
||||
def create_permission_setting():
|
||||
"""
|
||||
Create a new markdown permission setting.
|
||||
|
||||
This endpoint creates a new markdown permission setting with the provided permission value.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- permission (str): The permission value (e.g., 'private', 'protected', etc.)
|
||||
|
||||
Returns:
|
||||
A JSON object containing the created permission setting.
|
||||
|
||||
Response Codes:
|
||||
- 201: Created successfully
|
||||
"""
|
||||
data = request.json
|
||||
permission = data.get('permission')
|
||||
new_setting = MarkdownPermissionSetting(permission=permission)
|
||||
with get_db() as session:
|
||||
session.add(new_setting)
|
||||
session.commit()
|
||||
return jsonify(new_setting.to_dict()), 201
|
||||
|
||||
|
||||
@markdown_permission_setting_bp.route('/<int:setting_id>', methods=['PUT', 'PATCH'])
|
||||
@require_auth(roles=['admin'])
|
||||
def update_permission_setting(setting_id):
|
||||
"""
|
||||
Update a markdown permission setting.
|
||||
|
||||
This endpoint updates an existing markdown permission setting with the provided permission value.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
- PUT: Replaces the entire permission setting
|
||||
- PATCH: Updates only the specified fields
|
||||
|
||||
Request:
|
||||
- setting_id (int): The ID of the permission setting to update
|
||||
- permission (str): The new permission value
|
||||
|
||||
Returns:
|
||||
A JSON object containing the updated permission setting.
|
||||
|
||||
Response Codes:
|
||||
- 200: Updated successfully
|
||||
- 404: Permission setting not found
|
||||
"""
|
||||
with get_db() as session:
|
||||
setting = session.get(MarkdownPermissionSetting, setting_id)
|
||||
if setting is None:
|
||||
return jsonify({"error": "permission setting not found"}), 404
|
||||
data = request.json
|
||||
if request.method == 'PUT':
|
||||
setting.permission = data.get('permission')
|
||||
elif request.method == 'PATCH':
|
||||
if 'permission' in data:
|
||||
setting.permission = data.get('permission')
|
||||
session.commit()
|
||||
return jsonify(setting.to_dict()), 200
|
||||
|
||||
@markdown_permission_setting_bp.route('/<int:setting_id>', methods=['DELETE'])
|
||||
@require_auth(roles=['admin'])
|
||||
def delete_permission_setting(setting_id):
|
||||
"""
|
||||
Delete a markdown permission setting.
|
||||
|
||||
This endpoint deletes an existing markdown permission setting.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- setting_id (int): The ID of the permission setting to delete
|
||||
|
||||
Returns:
|
||||
A JSON object containing the deleted permission setting.
|
||||
|
||||
Response Codes:
|
||||
- 200: Deleted successfully
|
||||
"""
|
||||
with get_db() as session:
|
||||
setting = session.get(MarkdownPermissionSetting, setting_id)
|
||||
st = setting.to_dict()
|
||||
session.delete(setting)
|
||||
session.commit()
|
||||
return jsonify(st), 200
|
||||
139
api/setting/markdown/template.py
Normal file
139
api/setting/markdown/template.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from flask import jsonify, request, Blueprint
|
||||
import api
|
||||
from api import etag_response, limiter, require_auth
|
||||
from db import get_db
|
||||
from db.models.MarkdownTemplateSetting import MarkdownTemplateSetting
|
||||
|
||||
|
||||
markdown_template_setting_bp = Blueprint('markdown_template_setting', __name__, url_prefix='/api/setting/markdown/template')
|
||||
@markdown_template_setting_bp.route('/', methods=['GET'])
|
||||
@etag_response
|
||||
def list_template_settings():
|
||||
"""
|
||||
List all markdown template settings.
|
||||
|
||||
This endpoint retrieves a list of all markdown template settings in the system.
|
||||
|
||||
Returns:
|
||||
A JSON array containing all template settings.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
"""
|
||||
with get_db() as session:
|
||||
settings = session.query(MarkdownTemplateSetting).all()
|
||||
return jsonify([s.to_dict() for s in settings]), 200
|
||||
|
||||
@markdown_template_setting_bp.route('/<int:setting_id>', methods=['GET'])
|
||||
@etag_response
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def get_template_setting(setting_id):
|
||||
"""
|
||||
Get a specific markdown template setting by ID.
|
||||
|
||||
This endpoint retrieves a markdown template setting by its ID.
|
||||
|
||||
Request:
|
||||
- setting_id (int): The ID of the template setting to retrieve
|
||||
|
||||
Returns:
|
||||
A JSON object containing the template setting.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
- 204: No content (setting not found)
|
||||
"""
|
||||
with get_db() as session:
|
||||
setting = session.query(MarkdownTemplateSetting).get(setting_id)
|
||||
if not setting:
|
||||
return jsonify({}), 204
|
||||
return jsonify(setting.to_dict()), 200
|
||||
|
||||
|
||||
@markdown_template_setting_bp.route('/', methods=['POST'])
|
||||
@require_auth(roles=['admin'])
|
||||
def create_template_setting():
|
||||
"""
|
||||
Create a new markdown template setting.
|
||||
|
||||
This endpoint creates a new markdown template setting with the provided template ID.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- template_id (int): The ID of the template to associate with this setting
|
||||
|
||||
Returns:
|
||||
A JSON object containing the created template setting.
|
||||
|
||||
Response Codes:
|
||||
- 201: Created successfully
|
||||
"""
|
||||
data = request.json
|
||||
template_id = data.get('template_id')
|
||||
new_setting = MarkdownTemplateSetting(template_id=template_id)
|
||||
with get_db() as session:
|
||||
session.add(new_setting)
|
||||
session.commit()
|
||||
return jsonify(new_setting.to_dict()), 201
|
||||
|
||||
|
||||
@markdown_template_setting_bp.route('/<int:setting_id>', methods=['PUT', 'PATCH'])
|
||||
@require_auth(roles=['admin'])
|
||||
def update_template_setting(setting_id):
|
||||
"""
|
||||
Update a markdown template setting.
|
||||
|
||||
This endpoint updates an existing markdown template setting with the provided template ID.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
- PUT: Replaces the entire template setting
|
||||
- PATCH: Updates only the specified fields
|
||||
|
||||
Request:
|
||||
- setting_id (int): The ID of the template setting to update
|
||||
- template_id (int): The new template ID
|
||||
|
||||
Returns:
|
||||
A JSON object containing the updated template setting.
|
||||
|
||||
Response Codes:
|
||||
- 200: Updated successfully
|
||||
- 404: Template setting not found
|
||||
"""
|
||||
with get_db() as session:
|
||||
setting = session.get(MarkdownTemplateSetting, setting_id)
|
||||
if setting is None:
|
||||
return jsonify({"error": "template setting not found"}), 404
|
||||
data = request.json
|
||||
if request.method == 'PUT':
|
||||
setting.template_id = data.get('template_id')
|
||||
elif request.method == 'PATCH':
|
||||
if 'template_id' in data:
|
||||
setting.template_id = data.get('template_id')
|
||||
session.commit()
|
||||
return jsonify(setting.to_dict()), 200
|
||||
|
||||
@markdown_template_setting_bp.route('/<int:setting_id>', methods=['DELETE'])
|
||||
@require_auth(roles=['admin'])
|
||||
def delete_template_setting(setting_id):
|
||||
"""
|
||||
Delete a markdown template setting.
|
||||
|
||||
This endpoint deletes an existing markdown template setting.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- setting_id (int): The ID of the template setting to delete
|
||||
|
||||
Returns:
|
||||
A JSON object containing the deleted template setting.
|
||||
|
||||
Response Codes:
|
||||
- 200: Deleted successfully
|
||||
"""
|
||||
with get_db() as session:
|
||||
setting = session.get(MarkdownTemplateSetting, setting_id)
|
||||
st = setting.to_dict()
|
||||
session.delete(setting)
|
||||
session.commit()
|
||||
return jsonify(st), 200
|
||||
136
api/setting/path/__init__.py
Normal file
136
api/setting/path/__init__.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from flask import jsonify, request, Blueprint
|
||||
|
||||
import api
|
||||
from api import limiter, require_auth, etag_response
|
||||
from db import get_db
|
||||
from db.models.PathSetting import PathSetting
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
path_setting_bp = Blueprint('path_setting', __name__, url_prefix='/api/setting/path')
|
||||
|
||||
@path_setting_bp.route('/<int:setting_id>', methods=['GET'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@etag_response
|
||||
def get_path_setting(setting_id):
|
||||
"""
|
||||
Get a specific path setting by ID.
|
||||
|
||||
This endpoint retrieves a path setting by its ID.
|
||||
|
||||
Request:
|
||||
- setting_id (int): The ID of the path setting to retrieve
|
||||
|
||||
Returns:
|
||||
A JSON object containing the path setting.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
- 204: No content (setting not found)
|
||||
"""
|
||||
with get_db() as session:
|
||||
setting = session.query(PathSetting).get(setting_id)
|
||||
if setting is None:
|
||||
return jsonify({}), 204
|
||||
return jsonify(setting.to_dict()), 200
|
||||
|
||||
@path_setting_bp.route('/', methods=['POST'])
|
||||
@require_auth(roles=['admin'])
|
||||
def create_path_setting():
|
||||
"""
|
||||
Create a new path setting.
|
||||
|
||||
This endpoint creates a new path setting with the provided webhook and template settings.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- webhook_setting_id (int, optional): The ID of the webhook setting to associate
|
||||
- template_setting_id (int, optional): The ID of the template setting to associate
|
||||
|
||||
Returns:
|
||||
A JSON object containing the created path setting.
|
||||
|
||||
Response Codes:
|
||||
- 200: Created successfully
|
||||
- 500: Server error
|
||||
"""
|
||||
data = request.json
|
||||
webhook_setting_id = data.get('webhook_setting_id')
|
||||
template_setting_id = data.get('template_setting_id')
|
||||
setting = PathSetting(
|
||||
webhook_setting_id=webhook_setting_id,
|
||||
template_setting_id=template_setting_id
|
||||
)
|
||||
with get_db() as session:
|
||||
try:
|
||||
session.add(setting)
|
||||
session.commit()
|
||||
return jsonify(setting.to_dict()), 200
|
||||
except Exception:
|
||||
logger.error(f"failed to create path setting")
|
||||
return jsonify({"error": "failed to create setting"}), 500
|
||||
|
||||
|
||||
@path_setting_bp.route('/<int:setting_id>', methods=['PUT', 'PATCH'])
|
||||
@require_auth(roles=['admin'])
|
||||
def update_path_setting(setting_id):
|
||||
"""
|
||||
Update a path setting.
|
||||
|
||||
This endpoint updates an existing path setting with the provided webhook and template settings.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- setting_id (int): The ID of the path setting to update
|
||||
- webhook_setting_id (int, optional): The new webhook setting ID
|
||||
- template_setting_id (int, optional): The new template setting ID
|
||||
|
||||
Returns:
|
||||
A JSON object containing the updated path setting.
|
||||
|
||||
Response Codes:
|
||||
- 200: Updated successfully
|
||||
- 400: Bad request (setting not found)
|
||||
- 500: Server error
|
||||
"""
|
||||
data = request.json
|
||||
with get_db() as session:
|
||||
try:
|
||||
setting = session.query(PathSetting).get(setting_id)
|
||||
if setting is None:
|
||||
return jsonify({"error": "setting not exists"}), 400
|
||||
webhook_setting_id = data.get('webhook_setting_id', setting.webhook_setting_id)
|
||||
template_setting_id = data.get('template_setting_id', setting.template_setting_id)
|
||||
setting.webhook_setting_id = webhook_setting_id
|
||||
setting.template_setting_id = template_setting_id
|
||||
session.commit()
|
||||
return jsonify(setting.to_dict()), 200
|
||||
except Exception:
|
||||
return jsonify({"error": "failed to update path setting"}), 500
|
||||
|
||||
@path_setting_bp.route('/<int:setting_id>', methods=['DELETE'])
|
||||
@require_auth(roles=['admin'])
|
||||
def delete_path_setting(setting_id):
|
||||
"""
|
||||
Delete a path setting.
|
||||
|
||||
This endpoint deletes an existing path setting.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- setting_id (int): The ID of the path setting to delete
|
||||
|
||||
Returns:
|
||||
A JSON object with a success message.
|
||||
|
||||
Response Codes:
|
||||
- 200: Deleted successfully
|
||||
- 400: Bad request (setting not found)
|
||||
"""
|
||||
with get_db() as session:
|
||||
setting = session.query(PathSetting).get(setting_id)
|
||||
if setting is None:
|
||||
return jsonify({"error": "setting not exists"}), 400
|
||||
session.delete(setting)
|
||||
return jsonify({"message": "deleted"}), 200
|
||||
159
api/setting/path/webhook.py
Normal file
159
api/setting/path/webhook.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from flask import jsonify, request, Blueprint
|
||||
from api import require_auth
|
||||
from db import get_db
|
||||
from db.models.WebhookSetting import WebhookSetting
|
||||
|
||||
path_webhook_setting_bp = Blueprint('path_webhook_setting', __name__, url_prefix='/api/setting/path/webhook')
|
||||
@path_webhook_setting_bp.route('/', methods=['GET'])
|
||||
@require_auth(roles=['admin'])
|
||||
def list_webhook_settings():
|
||||
"""
|
||||
List all webhook settings.
|
||||
|
||||
This endpoint retrieves a list of all webhook settings in the system.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Returns:
|
||||
A JSON array containing all webhook settings.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
"""
|
||||
with get_db() as session:
|
||||
settings = session.query(WebhookSetting).all()
|
||||
return jsonify([s.to_dict() for s in settings]), 200
|
||||
|
||||
|
||||
@path_webhook_setting_bp.route('/<int:setting_id>', methods=['GET'])
|
||||
@require_auth(roles=['admin'])
|
||||
def webhook_setting(setting_id):
|
||||
"""
|
||||
Get a specific webhook setting by ID.
|
||||
|
||||
This endpoint retrieves a webhook setting by its ID.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- setting_id (int): The ID of the webhook setting to retrieve
|
||||
|
||||
Returns:
|
||||
A JSON object containing the webhook setting.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
- 204: No content (setting not found)
|
||||
"""
|
||||
with get_db() as session:
|
||||
setting = session.query(WebhookSetting).filter(WebhookSetting.id == setting_id).first()
|
||||
if not setting:
|
||||
return jsonify({}), 204
|
||||
return jsonify(setting.to_dict()), 200
|
||||
|
||||
|
||||
@path_webhook_setting_bp.route('/', methods=['POST'])
|
||||
@require_auth(roles=['admin'])
|
||||
def create_webhook_setting():
|
||||
"""
|
||||
Create a new webhook setting.
|
||||
|
||||
This endpoint creates a new webhook setting with the provided parameters.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- webhook_id (int): The ID of the webhook to associate
|
||||
- recursive (bool, optional): Whether the webhook applies to subpaths (default: false)
|
||||
- additional_header (str, optional): Additional headers for the webhook (default: '')
|
||||
- enabled (bool, optional): Whether the webhook is enabled (default: true)
|
||||
- on_events (int, optional): Event types that trigger the webhook (default: 1)
|
||||
|
||||
Returns:
|
||||
A JSON object containing the created webhook setting.
|
||||
|
||||
Response Codes:
|
||||
- 201: Created successfully
|
||||
"""
|
||||
data = request.json
|
||||
with get_db() as session:
|
||||
setting = WebhookSetting(
|
||||
webhook_id=data.get('webhook_id'),
|
||||
recursive=data.get('recursive', False),
|
||||
additional_header=data.get('additional_header', ''),
|
||||
enabled=data.get('enabled', True),
|
||||
on_events=data.get('on_events', 1),
|
||||
)
|
||||
session.add(setting)
|
||||
session.commit()
|
||||
return jsonify(setting.to_dict()), 201
|
||||
|
||||
|
||||
@path_webhook_setting_bp.route('/<int:setting_id>', methods=['PUT', 'PATCH'])
|
||||
@require_auth(roles=['admin'])
|
||||
def update_webhook_setting(setting_id):
|
||||
"""
|
||||
Update a webhook setting.
|
||||
|
||||
This endpoint updates an existing webhook setting with the provided parameters.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- setting_id (int): The ID of the webhook setting to update
|
||||
- webhook_id (int, optional): The new webhook ID
|
||||
- recursive (bool, optional): Whether the webhook applies to subpaths
|
||||
- additional_header (str, optional): Additional headers for the webhook
|
||||
- enabled (bool, optional): Whether the webhook is enabled
|
||||
- on_events (int, optional): Event types that trigger the webhook
|
||||
|
||||
Returns:
|
||||
A JSON object containing the updated webhook setting.
|
||||
|
||||
Response Codes:
|
||||
- 200: Updated successfully
|
||||
- 404: Webhook setting not found
|
||||
"""
|
||||
data = request.json
|
||||
with get_db() as session:
|
||||
setting = session.query(WebhookSetting).get(setting_id)
|
||||
if not setting:
|
||||
return jsonify({'error': 'Webhook setting not found'}), 404
|
||||
if 'webhook_id' in data:
|
||||
setting.webhook_id = data['webhook_id']
|
||||
if 'recursive' in data:
|
||||
setting.recursive = data['recursive']
|
||||
if 'additional_header' in data:
|
||||
setting.additional_header = data['additional_header']
|
||||
if 'enabled' in data:
|
||||
setting.enabled = data['enabled']
|
||||
if 'on_events' in data:
|
||||
setting.on_events = data['on_events']
|
||||
|
||||
session.commit()
|
||||
return jsonify(setting.to_dict()), 200
|
||||
|
||||
|
||||
@path_webhook_setting_bp.route('/<int:setting_id>', methods=['DELETE'])
|
||||
@require_auth(roles=['admin'])
|
||||
def delete_webhook_setting(setting_id):
|
||||
"""
|
||||
Delete a webhook setting.
|
||||
|
||||
This endpoint deletes an existing webhook setting.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- setting_id (int): The ID of the webhook setting to delete
|
||||
|
||||
Returns:
|
||||
A JSON object with a success message.
|
||||
|
||||
Response Codes:
|
||||
- 200: Deleted successfully
|
||||
- 404: Webhook setting not found
|
||||
"""
|
||||
with get_db() as session:
|
||||
setting = session.query(WebhookSetting).get(setting_id)
|
||||
if not setting:
|
||||
return jsonify({'error': 'Webhook setting not found'}), 404
|
||||
|
||||
session.delete(setting)
|
||||
session.commit()
|
||||
return jsonify({'message': 'Webhook setting deleted'}), 200
|
||||
9
api/template/__init__.py
Normal file
9
api/template/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
import importlib
|
||||
import pkgutil
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
template_bp = Blueprint('template', __name__, url_prefix='/api/template')
|
||||
|
||||
for loader, module_name, is_pkg in pkgutil.walk_packages(__path__, prefix=__name__ + "."):
|
||||
importlib.import_module(module_name)
|
||||
172
api/template/markdown/__init__.py
Normal file
172
api/template/markdown/__init__.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from flask import jsonify, request, Blueprint
|
||||
from api import etag_response, require_auth
|
||||
from db import get_db
|
||||
from db.models.MarkdownTemplate import MarkdownTemplate
|
||||
|
||||
cached_templates = {}
|
||||
|
||||
markdown_template_bp = Blueprint('markdown_template', __name__, url_prefix='/api/template/markdown')
|
||||
|
||||
def inflate_template(template):
|
||||
for parameter in template.get('parameters'):
|
||||
if parameter.get('type', {}).get('base_type') == 'template':
|
||||
sub_template_id = parameter.get('type', {}).get('definition', {}).get('template', {}).get('id', 0)
|
||||
if sub_template_id in cached_templates.keys():
|
||||
parameter['type']['definition']['template'] = cached_templates[sub_template_id]
|
||||
else:
|
||||
with get_db() as session:
|
||||
sub_template = session.query(MarkdownTemplate).get(sub_template_id)
|
||||
parameter['type']['definition']['template'] = inflate_template(sub_template)
|
||||
cached_templates[template['id']] = template
|
||||
return template
|
||||
|
||||
|
||||
@markdown_template_bp.route('/<int:template_id>', methods=['GET'])
|
||||
@etag_response
|
||||
def get_markdown_template(template_id):
|
||||
"""
|
||||
Get a specific markdown template by ID.
|
||||
|
||||
This endpoint retrieves a markdown template by its ID.
|
||||
The template is inflated to include any nested templates.
|
||||
|
||||
Request:
|
||||
- template_id (int): The ID of the template to retrieve
|
||||
|
||||
Returns:
|
||||
A JSON object containing the markdown template.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
- 204: No content (template not found)
|
||||
"""
|
||||
with get_db() as session:
|
||||
template = session.query(MarkdownTemplate).get(template_id)
|
||||
if template is None:
|
||||
return jsonify({}), 204
|
||||
return jsonify(inflate_template(template.to_dict())), 200
|
||||
|
||||
@markdown_template_bp.route('/', methods=['GET'])
|
||||
@etag_response
|
||||
def get_markdown_templates():
|
||||
"""
|
||||
List all markdown templates.
|
||||
|
||||
This endpoint retrieves a list of all markdown templates in the system.
|
||||
Each template is inflated to include any nested templates.
|
||||
|
||||
Returns:
|
||||
A JSON array containing all markdown templates.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
"""
|
||||
with get_db() as session:
|
||||
templates = session.query(MarkdownTemplate).all()
|
||||
return jsonify([inflate_template(template.to_dict()) for template in templates]), 200
|
||||
|
||||
|
||||
@markdown_template_bp.route('/', methods=['POST'])
|
||||
@require_auth(roles=['admin'])
|
||||
def create_markdown_template():
|
||||
"""
|
||||
Create a new markdown template.
|
||||
|
||||
This endpoint creates a new markdown template with the provided data.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- title (str): The title of the template (required)
|
||||
- parameters (object, optional): The parameters for the template
|
||||
- layout (str, optional): The layout content of the template
|
||||
|
||||
Returns:
|
||||
A JSON object containing the created template.
|
||||
|
||||
Response Codes:
|
||||
- 200: Created successfully
|
||||
- 400: Bad request (missing title or other error)
|
||||
"""
|
||||
data = request.json
|
||||
if "title" not in data:
|
||||
return jsonify({"error": "title is missing"}), 400
|
||||
title = data.get("title")
|
||||
parameters = data.get("parameters")
|
||||
layout = data.get("layout")
|
||||
template = MarkdownTemplate(title=title, parameters=parameters, layout=layout)
|
||||
try:
|
||||
with get_db() as session:
|
||||
session.add(template)
|
||||
session.commit()
|
||||
return jsonify(template.to_dict()), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": "failed to create markdown template"}), 400
|
||||
|
||||
|
||||
@markdown_template_bp.route('/<int:template_id>', methods=['PUT', 'PATCH'])
|
||||
@require_auth(roles=['admin'])
|
||||
def update_markdown_template(template_id):
|
||||
"""
|
||||
Update a markdown template.
|
||||
|
||||
This endpoint updates an existing markdown template with the provided data.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- template_id (int): The ID of the template to update
|
||||
- title (str, optional): The new title for the template
|
||||
- parameters (object, optional): The new parameters for the template
|
||||
- layout (str, optional): The new layout content for the template
|
||||
|
||||
Returns:
|
||||
A JSON object containing the updated template.
|
||||
|
||||
Response Codes:
|
||||
- 200: Updated successfully
|
||||
- 400: Bad request (template not found)
|
||||
"""
|
||||
data = request.json
|
||||
with get_db() as session:
|
||||
template = session.query(MarkdownTemplate).get(template_id)
|
||||
if template is None:
|
||||
return jsonify({'error': 'markdown template not found'}), 400
|
||||
title = data.get("title", template.title)
|
||||
parameters = data.get("parameters", template.parameters)
|
||||
layout = data.get("layout", template.layout)
|
||||
template.title = title
|
||||
template.parameters = parameters
|
||||
template.layout = layout
|
||||
session.commit()
|
||||
if template_id in cached_templates.keys():
|
||||
cached_templates[template_id] = inflate_template(template.to_dict())
|
||||
return jsonify(template.to_dict()), 200
|
||||
|
||||
|
||||
@markdown_template_bp.route('/<int:template_id>', methods=['DELETE'])
|
||||
@require_auth(roles=['admin'])
|
||||
def delete_markdown_template(template_id):
|
||||
"""
|
||||
Delete a markdown template.
|
||||
|
||||
This endpoint deletes an existing markdown template.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- template_id (int): The ID of the template to delete
|
||||
|
||||
Returns:
|
||||
A JSON object with a success message.
|
||||
|
||||
Response Codes:
|
||||
- 200: Deleted successfully
|
||||
- 400: Bad request (template not found)
|
||||
"""
|
||||
with get_db() as session:
|
||||
template = session.query(MarkdownTemplate).get(template_id)
|
||||
if template is None:
|
||||
return jsonify({'error': 'markdown template not found'}), 400
|
||||
session.delete(template)
|
||||
session.commit()
|
||||
if template_id in cached_templates.keys():
|
||||
cached_templates.pop(template_id)
|
||||
return jsonify({'message': 'deleted'}), 200
|
||||
147
api/template/path/__init__.py
Normal file
147
api/template/path/__init__.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from flask import jsonify, request
|
||||
from flask.sansio.blueprints import Blueprint
|
||||
|
||||
from api import etag_response, require_auth
|
||||
from api.template import template_bp
|
||||
from db import get_db
|
||||
from db.models.PathTemplate import PathTemplate
|
||||
|
||||
path_template_bp = Blueprint('path_template', __name__, url_prefix='/api/template/path')
|
||||
@path_template_bp.route('/<int:template_id>', methods=['GET'])
|
||||
@etag_response
|
||||
def get_path_template(template_id):
|
||||
"""
|
||||
Get a specific path template by ID.
|
||||
|
||||
This endpoint retrieves a path template by its ID.
|
||||
|
||||
Request:
|
||||
- template_id (int): The ID of the template to retrieve
|
||||
|
||||
Returns:
|
||||
A JSON object containing the path template.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
- 204: No content (template not found)
|
||||
"""
|
||||
with get_db() as session:
|
||||
template = session.query(PathTemplate).get(template_id)
|
||||
if template is None:
|
||||
return jsonify({}), 204
|
||||
return jsonify(template.to_dict()), 200
|
||||
|
||||
@path_template_bp.route('/', methods=['GET'])
|
||||
@etag_response
|
||||
def get_path_templates():
|
||||
"""
|
||||
List all path templates.
|
||||
|
||||
This endpoint retrieves a list of all path templates in the system.
|
||||
|
||||
Returns:
|
||||
A JSON array containing all path templates.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
"""
|
||||
with get_db() as session:
|
||||
templates = session.query(PathTemplate).all()
|
||||
return jsonify([template.to_dict() for template in templates]), 200
|
||||
|
||||
|
||||
@path_template_bp.route('/', methods=['POST'])
|
||||
@require_auth(roles=['admin'])
|
||||
def create_path_template():
|
||||
"""
|
||||
Create a new path template.
|
||||
|
||||
This endpoint creates a new path template with the provided data.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- title (str): The title of the template (required)
|
||||
- structure (str): The structure definition for the template
|
||||
|
||||
Returns:
|
||||
A JSON object containing the created template.
|
||||
|
||||
Response Codes:
|
||||
- 200: Created successfully
|
||||
- 400: Bad request (missing title or other error)
|
||||
"""
|
||||
data = request.json
|
||||
if "title" not in data:
|
||||
return jsonify({"error": "title is missing"}), 400
|
||||
title = data.get("title")
|
||||
structure = data.get("structure")
|
||||
template = PathTemplate(title=title, structure=structure)
|
||||
try:
|
||||
with get_db() as session:
|
||||
session.add(template)
|
||||
session.commit()
|
||||
return template.to_dict(), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": "failed to create path template"}), 400
|
||||
|
||||
|
||||
@path_template_bp.route('/<int:template_id>', methods=['PUT', 'PATCH'])
|
||||
@require_auth(roles=['admin'])
|
||||
def update_path_template(template_id):
|
||||
"""
|
||||
Update a path template.
|
||||
|
||||
This endpoint updates an existing path template with the provided data.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- template_id (int): The ID of the template to update
|
||||
- title (str, optional): The new title for the template
|
||||
- structure (str, optional): The new structure definition for the template
|
||||
|
||||
Returns:
|
||||
A JSON object containing the updated template.
|
||||
|
||||
Response Codes:
|
||||
- 200: Updated successfully
|
||||
- 400: Bad request (template not found)
|
||||
"""
|
||||
data = request.json
|
||||
with get_db() as session:
|
||||
template = session.query(PathTemplate).get(template_id)
|
||||
if template is None:
|
||||
return jsonify({'error': 'path template not found'}), 400
|
||||
title = data.get("title", template.title)
|
||||
structure = data.get("structure", template.structure)
|
||||
template.title = title
|
||||
template.structure = structure
|
||||
session.commit()
|
||||
return jsonify(template.to_dict()), 200
|
||||
|
||||
|
||||
@path_template_bp.route('/<int:template_id>', methods=['DELETE'])
|
||||
@require_auth(roles=['admin'])
|
||||
def delete_path_template(template_id):
|
||||
"""
|
||||
Delete a path template.
|
||||
|
||||
This endpoint deletes an existing path template.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- template_id (int): The ID of the template to delete
|
||||
|
||||
Returns:
|
||||
A JSON object with a success message.
|
||||
|
||||
Response Codes:
|
||||
- 200: Deleted successfully
|
||||
- 400: Bad request (template not found)
|
||||
"""
|
||||
with get_db() as session:
|
||||
template = session.query(PathTemplate).get(template_id)
|
||||
if template is None:
|
||||
return jsonify({'error': 'path template not found'}), 400
|
||||
session.delete(template)
|
||||
session.commit()
|
||||
return jsonify({'message': 'deleted'}), 200
|
||||
121
api/tree.py
Normal file
121
api/tree.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from flask import Blueprint, jsonify
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
|
||||
import api
|
||||
from api import etag_response, is_user_admin
|
||||
from db import get_db
|
||||
from db.models.Markdown import Markdown
|
||||
from db.models.Path import Path
|
||||
from db.models.MarkdownSetting import MarkdownSetting
|
||||
from db.models.MarkdownPermissionSetting import MarkdownPermissionSetting
|
||||
from api import limiter
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
tree_bp = Blueprint('tree', __name__, url_prefix='/api/tree')
|
||||
|
||||
|
||||
def build_tree(db: Session, parent_id: int = None, is_admin=False):
|
||||
path_nodes = db.query(Path).filter(Path.parent_id == parent_id).all()
|
||||
|
||||
markdown_query = db.query(
|
||||
Markdown.id,
|
||||
Markdown.title,
|
||||
Markdown.order,
|
||||
Markdown.shortcut,
|
||||
Markdown.setting_id
|
||||
).filter(Markdown.path_id == parent_id)
|
||||
|
||||
if not is_admin:
|
||||
markdown_query = markdown_query.outerjoin(
|
||||
MarkdownSetting,
|
||||
Markdown.setting_id == MarkdownSetting.id
|
||||
).outerjoin(
|
||||
MarkdownPermissionSetting,
|
||||
MarkdownSetting.permission_setting_id == MarkdownPermissionSetting.id
|
||||
).filter(
|
||||
or_(
|
||||
MarkdownPermissionSetting.permission != 'private',
|
||||
MarkdownPermissionSetting.permission == None
|
||||
)
|
||||
)
|
||||
|
||||
md_nodes = markdown_query.all()
|
||||
|
||||
t0 = [
|
||||
{
|
||||
"id": node.id,
|
||||
"title": node.title,
|
||||
"order": node.order,
|
||||
"setting_id": node.setting_id,
|
||||
"type": "markdown"
|
||||
} for node in md_nodes
|
||||
]
|
||||
t1 = [
|
||||
{**node.to_dict(), "type": "path", "children": build_tree(db, node.id, is_admin)} for node in path_nodes
|
||||
]
|
||||
for node in t1:
|
||||
for child in node["children"]:
|
||||
if "title" in child.keys() and child["title"] == "index":
|
||||
node["index"] = True
|
||||
break
|
||||
return t0 + t1
|
||||
|
||||
@tree_bp.route('/', methods=['GET'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@etag_response
|
||||
def get_tree():
|
||||
"""
|
||||
Get the complete tree structure of paths and markdowns.
|
||||
|
||||
This endpoint retrieves the hierarchical tree structure of all paths and markdowns.
|
||||
For non-admin users, markdowns with 'private' permission settings are filtered out.
|
||||
|
||||
Returns:
|
||||
A JSON object representing the tree structure with the following format:
|
||||
{
|
||||
"id": 1,
|
||||
"name": "",
|
||||
"parent_id": null,
|
||||
"order": "...",
|
||||
"setting_id": null,
|
||||
"type": "path",
|
||||
"index": true/false,
|
||||
"children": [
|
||||
{
|
||||
"id": ...,
|
||||
"title": "...",
|
||||
"order": "...",
|
||||
"setting_id": null,
|
||||
"type": "markdown"
|
||||
},
|
||||
{
|
||||
"id": ...,
|
||||
"name": "...",
|
||||
"parent_id": 1,
|
||||
"order": "...",
|
||||
"setting_id": null,
|
||||
"type": "path",
|
||||
"children": [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
"""
|
||||
is_admin = is_user_admin()
|
||||
|
||||
with get_db() as session:
|
||||
children = build_tree(session, 1, is_admin)
|
||||
root = session.query(Path).get(1)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
**root.to_dict(),
|
||||
"type": "path",
|
||||
"index": any("title" in child.keys() and child["title"] == "index" for child in children),
|
||||
"children": children
|
||||
}
|
||||
), 200
|
||||
130
api/webhook.py
Normal file
130
api/webhook.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from api import require_auth
|
||||
from db import get_db
|
||||
from db.models.Webhook import Webhook
|
||||
|
||||
webhook_bp = Blueprint('webhook', __name__, url_prefix='/api/webhook')
|
||||
|
||||
|
||||
@webhook_bp.route('/', methods=['GET'])
|
||||
@require_auth(roles=['admin'])
|
||||
def list_webhooks():
|
||||
"""
|
||||
List all webhooks.
|
||||
|
||||
This endpoint retrieves a list of all webhooks in the system.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Returns:
|
||||
A JSON array containing all webhooks.
|
||||
|
||||
Response Codes:
|
||||
- 200: Success
|
||||
"""
|
||||
with get_db() as session:
|
||||
hooks = session.query(Webhook).all()
|
||||
return jsonify([h.to_dict() for h in hooks]), 200
|
||||
|
||||
|
||||
@webhook_bp.route('/', methods=['POST'])
|
||||
@require_auth(['admin'])
|
||||
def create_webhook():
|
||||
"""
|
||||
Create a new webhook.
|
||||
|
||||
This endpoint creates a new webhook with the provided URL.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- hook_url (str): The URL of the webhook
|
||||
|
||||
Returns:
|
||||
A JSON object containing the created webhook.
|
||||
|
||||
Response Codes:
|
||||
- 201: Created successfully
|
||||
- 400: Bad request (hook_url is missing)
|
||||
- 409: Conflict (webhook URL already exists)
|
||||
"""
|
||||
data = request.json
|
||||
hook_url = data.get('hook_url')
|
||||
if not hook_url:
|
||||
return jsonify({'error': 'hook_url required'}), 400
|
||||
|
||||
with get_db() as session:
|
||||
existing = session.query(Webhook).filter_by(hook_url=hook_url).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Webhook URL already exists'}), 409
|
||||
webhook = Webhook(hook_url=hook_url)
|
||||
session.add(webhook)
|
||||
session.commit()
|
||||
return jsonify(webhook.to_dict()), 201
|
||||
|
||||
|
||||
@webhook_bp.route('/<int:webhook_id>', methods=['PUT', 'PATCH'])
|
||||
@require_auth(roles=['admin'])
|
||||
def update_webhook(webhook_id):
|
||||
"""
|
||||
Update a webhook.
|
||||
|
||||
This endpoint updates an existing webhook with the provided URL.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- webhook_id (int): The ID of the webhook to update
|
||||
- hook_url (str): The new URL for the webhook
|
||||
|
||||
Returns:
|
||||
A JSON object containing the updated webhook.
|
||||
|
||||
Response Codes:
|
||||
- 200: Updated successfully
|
||||
- 400: Bad request (hook_url is missing)
|
||||
- 404: Webhook not found
|
||||
- 409: Conflict (webhook URL already exists)
|
||||
"""
|
||||
data = request.json
|
||||
if 'hook_url' not in data:
|
||||
return jsonify({'error': 'hook_url is required'}), 400
|
||||
|
||||
with get_db() as session:
|
||||
webhook = session.query(Webhook).get(webhook_id)
|
||||
if not webhook:
|
||||
return jsonify({'error': 'Webhook not found'}), 404
|
||||
|
||||
existing = session.query(Webhook).filter_by(hook_url=data['hook_url']).filter(Webhook.id != webhook_id).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Webhook URL already exists'}), 409
|
||||
|
||||
webhook.hook_url = data['hook_url']
|
||||
session.commit()
|
||||
return jsonify(webhook.to_dict()), 200
|
||||
|
||||
|
||||
@webhook_bp.route('/<int:webhook_id>', methods=['DELETE'])
|
||||
@require_auth(roles=['admin'])
|
||||
def delete_webhook(webhook_id):
|
||||
"""
|
||||
Delete a webhook.
|
||||
|
||||
This endpoint deletes an existing webhook.
|
||||
It requires authentication with the 'admin' role.
|
||||
|
||||
Request:
|
||||
- webhook_id (int): The ID of the webhook to delete
|
||||
|
||||
Returns:
|
||||
A JSON object with a success message.
|
||||
|
||||
Response Codes:
|
||||
- 200: Deleted successfully
|
||||
- 404: Webhook not found
|
||||
"""
|
||||
with get_db() as session:
|
||||
webhook = session.query(Webhook).get(webhook_id)
|
||||
if not webhook:
|
||||
return jsonify({'error': 'Webhook not found'}), 404
|
||||
|
||||
session.delete(webhook)
|
||||
session.commit()
|
||||
return jsonify({'message': 'Webhook deleted'}), 200
|
||||
30
app.py
30
app.py
@@ -1,5 +1,6 @@
|
||||
# app.py
|
||||
from pprint import pprint
|
||||
|
||||
from events.WebhookEventHandlers import register_all_webhook_event_handlers
|
||||
from logging_handlers.DatabaseLogHandler import DatabaseLogHandler
|
||||
from api import limiter
|
||||
from flask import Flask, request
|
||||
@@ -26,20 +27,25 @@ except Exception as e:
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = env_provider.SESSION_SECRET_KEY
|
||||
CORS(app, resources={r"/api/*": {"origins": [
|
||||
env_provider.KC_HOST,
|
||||
env_provider.FRONTEND_HOST,
|
||||
r"https?://localhost:\d+",
|
||||
r"https?://127\.0\.0\.1:\d+",
|
||||
r"https?://localhost"
|
||||
]}},
|
||||
CORS(app, resources={
|
||||
r"/api/*": {
|
||||
"origins": [
|
||||
env_provider.KC_HOST,
|
||||
env_provider.FRONTEND_HOST,
|
||||
r"https?://localhost:\d+",
|
||||
r"https?://127\.0\.0\.1:\d+",
|
||||
r"https?://localhost"
|
||||
],
|
||||
"supports_credentials": True
|
||||
}
|
||||
},
|
||||
expose_headers=['Content-Disposition']
|
||||
)
|
||||
|
||||
limiter.init_app(app)
|
||||
|
||||
api.register_blueprints(app)
|
||||
|
||||
register_all_webhook_event_handlers()
|
||||
@app.before_request
|
||||
def log_request():
|
||||
if request.path.startswith("/api/log"):
|
||||
@@ -47,10 +53,8 @@ def log_request():
|
||||
logger.info(f"Request received: {request.method} {request.path} from {request.remote_addr}")
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
api.init_rate_limits(app)
|
||||
#logger.info("Starting app")
|
||||
print("env")
|
||||
pprint(env_provider.summerize())
|
||||
app.run(host='0.0.0.0', port=5000)
|
||||
app.run(host='0.0.0.0', port=5000, debug=True, use_reloader=True)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import os
|
||||
import subprocess
|
||||
from contextlib import contextmanager
|
||||
from sqlalchemy.schema import CreateTable
|
||||
|
||||
@@ -9,7 +7,6 @@ from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.dialects.mysql import insert
|
||||
from sqlalchemy import create_engine, text, inspect
|
||||
from env_provider import DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD, DB_SCHEMA_UPDATED, ENVIRONMENT
|
||||
|
||||
engine = create_engine(f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}")
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
@@ -21,15 +18,6 @@ def get_db():
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# def dump_db():
|
||||
# try:
|
||||
# os.environ['MYSQL_PWD'] = DB_PASSWORD
|
||||
# dump_cmd = f"mysqldump --no-tablespaces -h {DB_HOST} -P {DB_PORT} -u {DB_USER} {DB_NAME} > /app/dump/db_dump.sql"
|
||||
# subprocess.run(dump_cmd, shell=True, check=True)
|
||||
# except subprocess.CalledProcessError as e:
|
||||
# print(f"Failed to dump database: {e}")
|
||||
# raise e
|
||||
|
||||
def clear_db():
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SET FOREIGN_KEY_CHECKS = 0;"))
|
||||
@@ -60,9 +48,9 @@ def init_payload():
|
||||
for model in table_models:
|
||||
print(str(CreateTable(model.__table__)))
|
||||
|
||||
print(f"MODEL -- {model}, {hasattr(model, '__pay_load__')}")
|
||||
if hasattr(model, "__pay_load__"):
|
||||
payload = model.__pay_load__[ENVIRONMENT]
|
||||
print(f"MODEL -- {model}, {hasattr(model, '__payload__')}")
|
||||
if hasattr(model, "__payload__"):
|
||||
payload = model.__payload__[ENVIRONMENT]
|
||||
print(f"- - [ - ] hasattr, {ENVIRONMENT} - {payload}")
|
||||
stmt = insert(model.__table__).values(payload).prefix_with("IGNORE")
|
||||
print(f"- - [ - ] {stmt}\n")
|
||||
@@ -73,11 +61,6 @@ def init_payload():
|
||||
|
||||
def setup_db():
|
||||
if DB_SCHEMA_UPDATED:
|
||||
# try:
|
||||
# dump_db()
|
||||
# print("[ x ] db dumped")
|
||||
# except Exception as e:
|
||||
# print(f"[ x ] Failed to dump database: {e}")
|
||||
clear_db()
|
||||
print("[ x ] db cleared")
|
||||
create_all()
|
||||
|
||||
25
db/models/APIKey.py
Normal file
25
db/models/APIKey.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from datetime import datetime, timedelta, UTC
|
||||
from sqlalchemy import Column, String, DateTime, Boolean, JSON
|
||||
from db.models import Base
|
||||
|
||||
class APIKey(Base):
|
||||
__tablename__ = 'apikey'
|
||||
|
||||
key = Column(String(64), primary_key=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
last_used_at = Column(DateTime)
|
||||
is_active = Column(Boolean, default=True)
|
||||
roles = Column(JSON, nullable=False, default=list)
|
||||
expire = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC) + timedelta(days=15))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"key": self.key,
|
||||
"name": self.name,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
|
||||
"is_active": self.is_active,
|
||||
"roles": self.roles,
|
||||
"expire": self.expire.isoformat() if self.expire else None
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
#db/models/Log.py
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text
|
||||
from db.models import Base
|
||||
import datetime
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, Text, Integer, String, DateTime, ForeignKey, Float, text, UniqueConstraint
|
||||
from sqlalchemy import Column, Text, Integer, String, DateTime, ForeignKey, UniqueConstraint
|
||||
from db.models import Base
|
||||
import datetime
|
||||
|
||||
@@ -9,10 +9,12 @@ class Markdown(Base):
|
||||
id = Column(Integer, primary_key=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(datetime.UTC))
|
||||
path_id = Column(Integer, ForeignKey('path.id'), nullable=False)
|
||||
order = Column(String(36), default=lambda: str(uuid.uuid4()))
|
||||
shortcut = Column(String(36), default="")
|
||||
setting_id = Column(Integer, ForeignKey("markdown_setting.id"), nullable=True)
|
||||
__table_args__ = (UniqueConstraint('path_id', 'title', name="unique_path_id_title"),)
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
@@ -22,12 +24,13 @@ class Markdown(Base):
|
||||
'path_id': self.path_id,
|
||||
'order': self.order,
|
||||
'shortcut': self.shortcut,
|
||||
'setting_id': self.setting_id,
|
||||
}
|
||||
__pay_load__ = {
|
||||
__payload__ = {
|
||||
'dev': [
|
||||
{'id': 1, 'title': 'index', 'content': ' ', 'created_at': datetime.datetime.utcnow, 'path_id': 1 },
|
||||
{'id': 1, 'title': 'index', 'content': '{"markdown": ""}', 'created_at': datetime.datetime.utcnow, 'path_id': 1 },
|
||||
],
|
||||
'prod': [
|
||||
{'id': 1, 'title': 'index', 'content': ' ', 'created_at': datetime.datetime.utcnow, 'path_id': 1},
|
||||
{'id': 1, 'title': 'index', 'content': '{"markdown": ""}', 'created_at': datetime.datetime.utcnow, 'path_id': 1},
|
||||
]
|
||||
}
|
||||
|
||||
15
db/models/MarkdownPermissionSetting.py
Normal file
15
db/models/MarkdownPermissionSetting.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from sqlalchemy import Column, Integer, ForeignKey, String
|
||||
|
||||
from db.models import Base
|
||||
|
||||
|
||||
class MarkdownPermissionSetting(Base):
|
||||
__tablename__ = 'markdown_permission_setting'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
permission = Column(String(255), nullable=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"permission": self.permission,
|
||||
}
|
||||
14
db/models/MarkdownSetting.py
Normal file
14
db/models/MarkdownSetting.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy import Column, Integer, ForeignKey
|
||||
from db.models import Base
|
||||
|
||||
class MarkdownSetting(Base):
|
||||
__tablename__ = 'markdown_setting'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
template_setting_id = Column(Integer, ForeignKey('markdown_template_setting.id'), nullable=True)
|
||||
permission_setting_id = Column(Integer, ForeignKey('markdown_permission_setting.id'), nullable=True)
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"template_setting_id": self.template_setting_id,
|
||||
"permission_setting_id": self.permission_setting_id,
|
||||
}
|
||||
18
db/models/MarkdownTemplate.py
Normal file
18
db/models/MarkdownTemplate.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from sqlalchemy import Column, Text, Integer, String, UniqueConstraint,JSON
|
||||
from db.models import Base
|
||||
|
||||
class MarkdownTemplate(Base):
|
||||
__tablename__ = 'markdown_template'
|
||||
id = Column(Integer, primary_key=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
parameters = Column(JSON, nullable=True)
|
||||
layout = Column(Text, nullable=True)
|
||||
__table_args__ = (UniqueConstraint("title", name="unique_title"),)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'title': self.title,
|
||||
'parameters': self.parameters,
|
||||
'layout': self.layout,
|
||||
}
|
||||
16
db/models/MarkdownTemplateSetting.py
Normal file
16
db/models/MarkdownTemplateSetting.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from sqlalchemy import Column, Integer, ForeignKey
|
||||
|
||||
from db.models import Base
|
||||
|
||||
|
||||
class MarkdownTemplateSetting(Base):
|
||||
__tablename__ = 'markdown_template_setting'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
template_id = Column(Integer, ForeignKey('markdown_template.id'))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'template_id': self.template_id,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, String, Integer, ForeignKey, UniqueConstraint, text
|
||||
from sqlalchemy import Column, String, Integer, ForeignKey, UniqueConstraint
|
||||
from db.models import Base
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ class Path(Base):
|
||||
name = Column(String(50), nullable=False)
|
||||
parent_id = Column(Integer, ForeignKey("path.id"), nullable=True)
|
||||
order = Column(String(36), default=lambda: str(uuid.uuid4()))
|
||||
setting_id = Column(Integer, ForeignKey("path_setting.id"), nullable=True)
|
||||
__table_args__ = (UniqueConstraint("parent_id", "name", name="unique_parent_id_name"), )
|
||||
def to_dict(self):
|
||||
return {
|
||||
@@ -17,9 +18,10 @@ class Path(Base):
|
||||
"name": self.name,
|
||||
"parent_id": self.parent_id,
|
||||
"order": self.order,
|
||||
"setting_id": self.setting_id
|
||||
}
|
||||
|
||||
__pay_load__ = {
|
||||
__payload__ = {
|
||||
'dev': [
|
||||
{'id': 1, 'name': '', 'parent_id': None},
|
||||
{'id': 2, 'name': 'test', 'parent_id': 1},
|
||||
|
||||
17
db/models/PathSetting.py
Normal file
17
db/models/PathSetting.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from sqlalchemy import Column, Integer, ForeignKey
|
||||
|
||||
from db.models import Base
|
||||
|
||||
|
||||
class PathSetting(Base):
|
||||
__tablename__ = 'path_setting'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
webhook_setting_id = Column(Integer, ForeignKey('webhook_setting.id'), nullable=True)
|
||||
template_setting_id = Column(Integer, ForeignKey('path_template.id'), nullable=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"webhook_setting_id": self.webhook_setting_id,
|
||||
"template_setting_id": self.template_setting_id,
|
||||
}
|
||||
18
db/models/PathTemplate.py
Normal file
18
db/models/PathTemplate.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from sqlalchemy import Column, Integer, Text, UniqueConstraint, String
|
||||
|
||||
from db.models import Base
|
||||
|
||||
|
||||
class PathTemplate(Base):
|
||||
__tablename__ = 'path_template'
|
||||
id = Column(Integer, primary_key=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
structure = Column(Text, nullable=False)
|
||||
__table_args__ = (UniqueConstraint("title", name="unique_title"),)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'title': self.title,
|
||||
'structure': self.structure
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
#db/models/Resource.py
|
||||
from sqlalchemy import Column, Text, LargeBinary, String
|
||||
from db.models import Base
|
||||
|
||||
|
||||
16
db/models/Webhook.py
Normal file
16
db/models/Webhook.py
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
from sqlalchemy import Column, String, Integer, ForeignKey, UniqueConstraint, text, Boolean
|
||||
from db.models import Base
|
||||
|
||||
|
||||
class Webhook(Base):
|
||||
__tablename__ = 'webhook'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
hook_url = Column(String(100), nullable=False)
|
||||
__table_args__ = (UniqueConstraint('hook_url'),)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"hook_url": self.hook_url
|
||||
}
|
||||
22
db/models/WebhookSetting.py
Normal file
22
db/models/WebhookSetting.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import Column, Text, Integer, ForeignKey, UniqueConstraint, Boolean
|
||||
from db.models import Base
|
||||
|
||||
|
||||
class WebhookSetting(Base):
|
||||
__tablename__ = 'webhook_setting'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
webhook_id = Column(Integer, ForeignKey('webhook.id'), nullable=True)
|
||||
recursive = Column(Boolean, default=False)
|
||||
additional_header = Column(Text, nullable=True)
|
||||
enabled = Column(Boolean, default=True)
|
||||
on_events = Column(Integer, default=0)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"webhook_id": self.webhook_id,
|
||||
"recursive": self.recursive,
|
||||
"additional_header": self.additional_header,
|
||||
"enabled": self.enabled,
|
||||
"on_events": self.on_events,
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
#db/models/__init__.py
|
||||
|
||||
import pkgutil
|
||||
import importlib
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import declarative_base
|
||||
Base = declarative_base()
|
||||
package_name = "db.models"
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#db/utils.py
|
||||
from db import get_db
|
||||
|
||||
def insert_log(log_entry):
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#env_provider.py
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from events import MARKDOWN_CREATED_EVENT, markdown_created
|
||||
from events.WebhookEventHandlers import auto_instantiate
|
||||
from events.WebhookEventHandlers.MarkdownWebhookEventHandlers import MarkdownWebhookEventHandler
|
||||
from misc import Singleton
|
||||
|
||||
|
||||
@auto_instantiate
|
||||
class MarkdownCreatedWebhookEventHandler(MarkdownWebhookEventHandler, Singleton):
|
||||
def __init__(self):
|
||||
if getattr(self, "_initialized", False):
|
||||
return
|
||||
super().__init__(MARKDOWN_CREATED_EVENT)
|
||||
markdown_created.connect(self)
|
||||
self._initialized = True
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from events import MARKDOWN_DELETED_EVENT, markdown_deleted
|
||||
from events.WebhookEventHandlers import auto_instantiate
|
||||
from events.WebhookEventHandlers.MarkdownWebhookEventHandlers import MarkdownWebhookEventHandler
|
||||
from misc import Singleton
|
||||
|
||||
|
||||
@auto_instantiate
|
||||
class MarkdownDeletedWebhookEventHandler(MarkdownWebhookEventHandler, Singleton):
|
||||
def __init__(self):
|
||||
if getattr(self, "_initialized", False):
|
||||
return
|
||||
super().__init__(MARKDOWN_DELETED_EVENT)
|
||||
markdown_deleted.connect(self)
|
||||
self._initialized = True
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from events import MARKDOWN_UPDATED_EVENT, markdown_updated
|
||||
from events.WebhookEventHandlers import auto_instantiate
|
||||
from events.WebhookEventHandlers.MarkdownWebhookEventHandlers import MarkdownWebhookEventHandler
|
||||
from misc import Singleton
|
||||
|
||||
|
||||
@auto_instantiate
|
||||
class MarkdownUpdatedWebhookEventHandler(MarkdownWebhookEventHandler, Singleton):
|
||||
def __init__(self):
|
||||
if getattr(self, "_initialized", False):
|
||||
return
|
||||
super().__init__(MARKDOWN_UPDATED_EVENT)
|
||||
markdown_updated.connect(self)
|
||||
self._initialized = True
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from events.WebhookEventHandlers import WebhookEventHandler
|
||||
|
||||
|
||||
class MarkdownWebhookEventHandler(WebhookEventHandler):
|
||||
def __init__(self, event_type):
|
||||
super().__init__(event_type)
|
||||
|
||||
def get_path_id(self, payload):
|
||||
return payload["path_id"]
|
||||
@@ -0,0 +1,26 @@
|
||||
from events import PATH_CREATED_EVENT, path_created
|
||||
from events.WebhookEventHandlers import auto_instantiate
|
||||
from events.WebhookEventHandlers.PathWebhookEventHandlers import PathWebhookEventHandler
|
||||
from misc import Singleton
|
||||
|
||||
|
||||
@auto_instantiate
|
||||
class PathCreatedWebhookEventHandler(PathWebhookEventHandler,Singleton):
|
||||
|
||||
_instance = None
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(PathCreatedWebhookEventHandler, cls).__new__(cls)
|
||||
return cls._instance
|
||||
def __init__(self):
|
||||
if getattr(self, "_initialized", False):
|
||||
return
|
||||
super().__init__(PATH_CREATED_EVENT)
|
||||
path_created.connect(self)
|
||||
self._initialized = True
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from events import PATH_DELETED_EVENT, path_deleted
|
||||
from events.WebhookEventHandlers import auto_instantiate
|
||||
from events.WebhookEventHandlers.PathWebhookEventHandlers import PathWebhookEventHandler
|
||||
from misc import Singleton
|
||||
|
||||
|
||||
@auto_instantiate
|
||||
class PathDeletedWebhookEventHandler(PathWebhookEventHandler, Singleton):
|
||||
_instance = None
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(PathDeletedWebhookEventHandler, cls).__new__(cls)
|
||||
return cls._instance
|
||||
def __init__(self):
|
||||
if getattr(self, "_initialized", False):
|
||||
return
|
||||
super().__init__(PATH_DELETED_EVENT)
|
||||
path_deleted.connect(self)
|
||||
self._initialized = True
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from events import PATH_UPDATED_EVENT, path_updated
|
||||
from events.WebhookEventHandlers import auto_instantiate
|
||||
from events.WebhookEventHandlers.PathWebhookEventHandlers import PathWebhookEventHandler
|
||||
from misc import Singleton
|
||||
|
||||
|
||||
@auto_instantiate
|
||||
class PathUpdatedWebhookEventHandler(PathWebhookEventHandler, Singleton):
|
||||
|
||||
_instance = None
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(PathUpdatedWebhookEventHandler, cls).__new__(cls)
|
||||
return cls._instance
|
||||
def __init__(self):
|
||||
if getattr(self, "_initialized", False):
|
||||
return
|
||||
super().__init__(PATH_UPDATED_EVENT)
|
||||
path_updated.connect(self)
|
||||
self._initialized = True
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from events.WebhookEventHandlers import WebhookEventHandler
|
||||
|
||||
|
||||
class PathWebhookEventHandler(WebhookEventHandler):
|
||||
def __init__(self, event_type):
|
||||
super().__init__(event_type)
|
||||
|
||||
def get_path_id(self, payload):
|
||||
return payload["id"]
|
||||
90
events/WebhookEventHandlers/__init__.py
Normal file
90
events/WebhookEventHandlers/__init__.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from db.models.Path import Path
|
||||
from db.models.PathSetting import PathSetting
|
||||
from db.models.Webhook import Webhook
|
||||
from db.models.WebhookSetting import WebhookSetting
|
||||
from events import MARKDOWN_CREATED_EVENT, MARKDOWN_UPDATED_EVENT, MARKDOWN_DELETED_EVENT, PATH_CREATED_EVENT, \
|
||||
PATH_UPDATED_EVENT, PATH_DELETED_EVENT
|
||||
import abc
|
||||
import importlib
|
||||
import json
|
||||
import pkgutil
|
||||
import requests
|
||||
import db
|
||||
|
||||
|
||||
event_type_map = {
|
||||
MARKDOWN_CREATED_EVENT: 'markdown_created_event',
|
||||
MARKDOWN_UPDATED_EVENT: 'markdown_updated_event',
|
||||
MARKDOWN_DELETED_EVENT: 'markdown_deleted_event',
|
||||
PATH_CREATED_EVENT: 'path_created_event',
|
||||
PATH_UPDATED_EVENT: 'path_updated_event',
|
||||
PATH_DELETED_EVENT: 'path_deleted_event',
|
||||
}
|
||||
class WebhookEventHandler(abc.ABC):
|
||||
def __init__(self, event_type=0):
|
||||
self.event_type = event_type
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_path_id(self, payload):
|
||||
pass
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
payload = kwargs['payload']
|
||||
path_id = self.get_path_id(payload)
|
||||
with db.get_db() as session:
|
||||
setting = self.get_setting(session, path_id)
|
||||
if setting is None:
|
||||
return
|
||||
headers = {'Content-Type': 'application/json', 'x-alchegos-event': event_type_map[self.event_type]}
|
||||
if setting.get("additional_header", None) is not None:
|
||||
headers.update(json.loads(setting["additional_header"]))
|
||||
body = json.dumps(payload, default=str)
|
||||
try:
|
||||
response = requests.post(setting["webhook_url"], data=body, headers=headers, timeout=5)
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
def get_setting(self, session: Session, path_id):
|
||||
|
||||
path = session.query(Path).filter(Path.id == path_id).first()
|
||||
if path is None:
|
||||
return None
|
||||
p = path.to_dict()
|
||||
path_setting = session.query(PathSetting).get(path.setting_id)
|
||||
if path_setting is None:
|
||||
return None
|
||||
webhook_setting = session.query(WebhookSetting).get(path_setting.webhook_setting_id)
|
||||
|
||||
if webhook_setting is None and p["parent_id"] != 1:
|
||||
return self.get_setting(session, p["parent_id"])
|
||||
|
||||
# Check if webhook_setting is still None (e.g., when parent_id == 1 or no parent found)
|
||||
if webhook_setting is None:
|
||||
return None
|
||||
|
||||
setting = webhook_setting.to_dict()
|
||||
if not setting["enabled"] or setting["on_events"] & self.event_type == 0:
|
||||
return None
|
||||
webhook = session.query(Webhook).get(webhook_setting.webhook_id)
|
||||
if webhook is None:
|
||||
return None
|
||||
setting["webhook_url"] = webhook.to_dict()["hook_url"]
|
||||
return setting
|
||||
|
||||
_auto_instantiate_classes = set()
|
||||
|
||||
def auto_instantiate(cls):
|
||||
_auto_instantiate_classes.add(cls)
|
||||
return cls
|
||||
|
||||
def register_all_webhook_event_handlers():
|
||||
package = __name__
|
||||
package_path = __path__
|
||||
|
||||
for finder, name, ispkg in pkgutil.walk_packages(package_path, package+"."):
|
||||
importlib.import_module(name)
|
||||
|
||||
for cls in _auto_instantiate_classes:
|
||||
cls()
|
||||
17
events/__init__.py
Normal file
17
events/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from blinker import Namespace
|
||||
|
||||
signals = Namespace()
|
||||
|
||||
MARKDOWN_CREATED_EVENT = 1
|
||||
MARKDOWN_UPDATED_EVENT = 2
|
||||
MARKDOWN_DELETED_EVENT = 4
|
||||
PATH_CREATED_EVENT = 8
|
||||
PATH_UPDATED_EVENT = 16
|
||||
PATH_DELETED_EVENT = 32
|
||||
|
||||
markdown_created = signals.signal('markdown_created')
|
||||
markdown_updated = signals.signal('markdown_updated')
|
||||
markdown_deleted = signals.signal('markdown_deleted')
|
||||
path_created = signals.signal('path_created')
|
||||
path_updated = signals.signal('path_updated')
|
||||
path_deleted = signals.signal('path_deleted')
|
||||
@@ -1,5 +1,3 @@
|
||||
#logging_handlers/DatabaseLogHandler.py
|
||||
|
||||
import logging
|
||||
from db import get_db
|
||||
from db.models.Log import Log
|
||||
|
||||
7
misc/__init__.py
Normal file
7
misc/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
class Singleton:
|
||||
_instances = {}
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls not in cls._instances:
|
||||
cls._instances[cls] = super(Singleton, cls).__new__(cls)
|
||||
return cls._instances[cls]
|
||||
172
misc/backup_converters/__init__.py
Normal file
172
misc/backup_converters/__init__.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Backup Conversion System
|
||||
========================
|
||||
|
||||
This module provides functionality for converting backups between different versions.
|
||||
Each version converter is implemented in a separate file and registered with the system.
|
||||
|
||||
Usage:
|
||||
------
|
||||
To convert a backup from one version to another:
|
||||
|
||||
```python
|
||||
from misc.backup_converters import convert_backup
|
||||
|
||||
# Convert a backup from version 1.0 to the latest version
|
||||
convert_backup(backup_dir, target_version=None) # None means latest version
|
||||
```
|
||||
|
||||
Adding a New Converter:
|
||||
----------------------
|
||||
1. Create a new file in the backup_converters directory, e.g., v1_to_v2.py
|
||||
2. Implement a function that takes a backup directory and converts it to the target version
|
||||
3. Register the converter in this file using the register_converter function
|
||||
|
||||
Example:
|
||||
```python
|
||||
# In v1_to_v2.py
|
||||
from misc.backup_converters import register_converter
|
||||
|
||||
def convert_v1_to_v2(backup_dir):
|
||||
# Conversion logic here
|
||||
return new_version
|
||||
|
||||
register_converter("1.0", "2.0", convert_v1_to_v2)
|
||||
```
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Callable, Tuple, Optional, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
CURRENT_VERSION = "1.0"
|
||||
|
||||
_converters: Dict[Tuple[str, str], Callable[[str], str]] = {}
|
||||
|
||||
def register_converter(from_version: str, to_version: str, converter: Callable[[str], str]):
|
||||
"""
|
||||
Register a converter function for a specific version transition.
|
||||
|
||||
Args:
|
||||
from_version (str): The source version
|
||||
to_version (str): The target version
|
||||
converter (callable): A function that takes a backup directory path and returns the new version
|
||||
"""
|
||||
_converters[(from_version, to_version)] = converter
|
||||
logger.info(f"Registered converter from version {from_version} to {to_version}")
|
||||
|
||||
def get_backup_version(backup_dir: str) -> str:
|
||||
"""
|
||||
Get the version of a backup.
|
||||
|
||||
Args:
|
||||
backup_dir (str): Path to the backup directory
|
||||
|
||||
Returns:
|
||||
str: The version of the backup, or "0.0" if no version is found
|
||||
"""
|
||||
version_file = os.path.join(backup_dir, "version.json")
|
||||
if os.path.exists(version_file):
|
||||
try:
|
||||
with open(version_file, 'r') as f:
|
||||
version_data = json.load(f)
|
||||
return version_data.get("version", "0.0")
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading version file: {e}")
|
||||
return "0.0"
|
||||
|
||||
def set_backup_version(backup_dir: str, version: str):
|
||||
"""
|
||||
Set the version of a backup.
|
||||
|
||||
Args:
|
||||
backup_dir (str): Path to the backup directory
|
||||
version (str): The version to set
|
||||
"""
|
||||
version_file = os.path.join(backup_dir, "version.json")
|
||||
try:
|
||||
with open(version_file, 'w') as f:
|
||||
json.dump({"version": version}, f)
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing version file: {e}")
|
||||
|
||||
def find_conversion_path(from_version: str, to_version: str) -> Optional[List[Tuple[str, str]]]:
|
||||
"""
|
||||
Find a path of converters to go from one version to another.
|
||||
|
||||
Args:
|
||||
from_version (str): The source version
|
||||
to_version (str): The target version
|
||||
|
||||
Returns:
|
||||
list: A list of (from, to) version pairs representing the conversion path,
|
||||
or None if no path is found
|
||||
"""
|
||||
if from_version == to_version:
|
||||
return []
|
||||
|
||||
if (from_version, to_version) in _converters:
|
||||
return [(from_version, to_version)]
|
||||
|
||||
queue = [(from_version, [])]
|
||||
visited = {from_version}
|
||||
|
||||
while queue:
|
||||
current, path = queue.pop(0)
|
||||
|
||||
for (src, dst), _ in _converters.items():
|
||||
if src == current and dst not in visited:
|
||||
new_path = path + [(src, dst)]
|
||||
if dst == to_version:
|
||||
return new_path
|
||||
visited.add(dst)
|
||||
queue.append((dst, new_path))
|
||||
|
||||
return None
|
||||
|
||||
def convert_backup(backup_dir: str, target_version: Optional[str] = None) -> str:
|
||||
"""
|
||||
Convert a backup to the target version.
|
||||
|
||||
Args:
|
||||
backup_dir (str): Path to the backup directory
|
||||
target_version (str, optional): The target version. If None, converts to the latest version.
|
||||
|
||||
Returns:
|
||||
str: The new version of the backup
|
||||
|
||||
Raises:
|
||||
ValueError: If no conversion path is found
|
||||
"""
|
||||
if target_version is None:
|
||||
target_version = CURRENT_VERSION
|
||||
|
||||
current_version = get_backup_version(backup_dir)
|
||||
if current_version == target_version:
|
||||
return current_version
|
||||
|
||||
path = find_conversion_path(current_version, target_version)
|
||||
if not path:
|
||||
raise ValueError(f"No conversion path found from version {current_version} to {target_version}")
|
||||
|
||||
for from_ver, to_ver in path:
|
||||
converter = _converters.get((from_ver, to_ver))
|
||||
if converter:
|
||||
logger.info(f"Converting backup from version {from_ver} to {to_ver}")
|
||||
new_version = converter(backup_dir)
|
||||
set_backup_version(backup_dir, new_version)
|
||||
else:
|
||||
raise ValueError(f"Converter not found for {from_ver} to {to_ver}")
|
||||
|
||||
return target_version
|
||||
|
||||
import pkgutil
|
||||
import importlib
|
||||
|
||||
for _, name, is_pkg in pkgutil.iter_modules([os.path.dirname(__file__)]):
|
||||
if not is_pkg and name != "__init__":
|
||||
importlib.import_module(f"misc.backup_converters.{name}")
|
||||
176
misc/backup_converters/v0_to_v1.py
Normal file
176
misc/backup_converters/v0_to_v1.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Converter from version 0.0 to version 1.0
|
||||
=========================================
|
||||
|
||||
This module converts backups from version 0.0 (old format without version info)
|
||||
to version 1.0 (current format with version info).
|
||||
|
||||
The old version backup has a simple directory structure with .md files for markdown content,
|
||||
.mdmeta files for markdown metadata, and .meta files for path metadata.
|
||||
|
||||
The current version backup has a more complex structure with tree/ and template/ directories,
|
||||
.json files for markdown content, .json.meta files for path metadata, and a webhook.json file.
|
||||
|
||||
For all old version markdowns, they are considered to be using the default template with no other settings,
|
||||
and their content is converted from plain text to {"markdown": content_in_md}.
|
||||
|
||||
For all old version paths, they are considered to have no settings.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import tempfile
|
||||
from misc.backup_converters import register_converter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def convert_v0_to_v1(backup_dir: str) -> str:
|
||||
"""
|
||||
Convert a backup from version 0.0 to version 1.0.
|
||||
|
||||
Args:
|
||||
backup_dir (str): Path to the backup directory
|
||||
|
||||
Returns:
|
||||
str: The new version ("1.0")
|
||||
"""
|
||||
logger.info(f"Converting backup from version 0.0 to 1.0: {backup_dir}")
|
||||
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
new_backup_dir = os.path.join(temp_dir, "new_backup")
|
||||
os.makedirs(new_backup_dir)
|
||||
|
||||
try:
|
||||
tree_dir = os.path.join(new_backup_dir, 'tree')
|
||||
template_dir = os.path.join(new_backup_dir, 'template')
|
||||
os.makedirs(tree_dir)
|
||||
os.makedirs(template_dir)
|
||||
|
||||
with open(os.path.join(new_backup_dir, 'webhook.json'), 'w') as f:
|
||||
json.dump([], f)
|
||||
|
||||
with open(os.path.join(new_backup_dir, 'version.json'), 'w') as f:
|
||||
json.dump({"version": "1.0"}, f)
|
||||
|
||||
root_dir = os.path.join(backup_dir, 'Root')
|
||||
if os.path.exists(root_dir) and os.path.isdir(root_dir):
|
||||
source_dir = root_dir
|
||||
else:
|
||||
source_dir = backup_dir
|
||||
|
||||
convert_directory(source_dir, tree_dir)
|
||||
|
||||
for item in os.listdir(new_backup_dir):
|
||||
src = os.path.join(new_backup_dir, item)
|
||||
dst = os.path.join(backup_dir, item)
|
||||
if os.path.isdir(src):
|
||||
if os.path.exists(dst):
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src, dst)
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
return "1.0"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting backup: {e}")
|
||||
shutil.rmtree(temp_dir)
|
||||
raise
|
||||
|
||||
def convert_directory(old_dir: str, new_dir: str):
|
||||
"""
|
||||
Recursively convert a directory from the old backup format to the new format.
|
||||
|
||||
This function processes a directory from the old backup format and converts it to the new format.
|
||||
It creates a .json.meta file for the directory with metadata from the .meta file (if it exists),
|
||||
processes all markdown files (.md) in the directory, converting them to .json files with the new format,
|
||||
and recursively processes all subdirectories.
|
||||
|
||||
For markdown files, the function:
|
||||
1. Reads the content from the .md file
|
||||
2. Converts the content to the new format {"markdown": content_in_md}
|
||||
3. Reads metadata from the .mdmeta file (if it exists)
|
||||
4. Creates a new .json file with the content and metadata
|
||||
|
||||
For directories, the function:
|
||||
1. Reads metadata from the .meta file (if it exists)
|
||||
2. Creates a new .json.meta file with the metadata
|
||||
3. Recursively processes all subdirectories
|
||||
|
||||
Args:
|
||||
old_dir (str): Path to the old directory
|
||||
new_dir (str): Path to the new directory where the converted files will be stored
|
||||
"""
|
||||
order = ''
|
||||
meta_file_path = os.path.join(old_dir, '.meta')
|
||||
if os.path.exists(meta_file_path):
|
||||
with open(meta_file_path, 'r') as f:
|
||||
for line in f:
|
||||
try:
|
||||
if line.startswith('order:'):
|
||||
order = line.strip().split(': ', 1)[1]
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
path_meta = {
|
||||
'name': os.path.basename(old_dir),
|
||||
'order': order,
|
||||
'backup_id': 0
|
||||
}
|
||||
|
||||
with open(os.path.join(new_dir, '.json.meta'), 'w') as f:
|
||||
json.dump(path_meta, f, default=str, indent=2)
|
||||
|
||||
for file_name in os.listdir(old_dir):
|
||||
old_file_path = os.path.join(old_dir, file_name)
|
||||
|
||||
if file_name.endswith('.md'):
|
||||
md_title = file_name[:-3]
|
||||
|
||||
with open(old_file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
new_content = json.dumps({'markdown': content})
|
||||
|
||||
created_at = datetime.now()
|
||||
order = ''
|
||||
shortcut = ''
|
||||
|
||||
mdmeta_path = old_file_path + 'meta'
|
||||
if os.path.exists(mdmeta_path):
|
||||
with open(mdmeta_path, 'r') as f:
|
||||
for line in f:
|
||||
try:
|
||||
key, value = line.strip().split(': ', 1)
|
||||
if key == 'created_at':
|
||||
created_at = value
|
||||
elif key == 'order':
|
||||
order = value
|
||||
elif key == 'shortcut':
|
||||
shortcut = value
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
md_data = {
|
||||
'title': md_title,
|
||||
'content': new_content,
|
||||
'created_at': created_at,
|
||||
'order': order,
|
||||
'shortcut': shortcut,
|
||||
'backup_id': 0
|
||||
}
|
||||
|
||||
with open(os.path.join(new_dir, f'{md_title}.json'), 'w') as f:
|
||||
json.dump(md_data, f, default=str, indent=2)
|
||||
|
||||
elif os.path.isdir(old_file_path) and not file_name.startswith('.'):
|
||||
new_subdir = os.path.join(new_dir, file_name)
|
||||
os.makedirs(new_subdir, exist_ok=True)
|
||||
convert_directory(old_file_path, new_subdir)
|
||||
|
||||
register_converter("0.0", "1.0", convert_v0_to_v1)
|
||||
9
req2.txt
9
req2.txt
@@ -1,9 +0,0 @@
|
||||
cryptography==44.0.0
|
||||
Flask==3.1.0
|
||||
Flask_Cors==5.0.0
|
||||
Flask_Limiter==3.9.2
|
||||
PyJWT==2.10.1
|
||||
PyJWT==2.10.1
|
||||
python-dotenv==1.0.1
|
||||
Requests==2.32.3
|
||||
SQLAlchemy==2.0.36
|
||||
5
requirements-test.txt
Normal file
5
requirements-test.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
# Test dependencies
|
||||
pytest==7.4.0
|
||||
pytest-flask==1.2.0
|
||||
pytest-cov==4.1.0
|
||||
pytest-mock==3.11.1
|
||||
@@ -34,3 +34,5 @@ typing_extensions==4.12.2
|
||||
urllib3==2.2.3
|
||||
Werkzeug==3.1.3
|
||||
wrapt==1.17.0
|
||||
|
||||
pytest~=8.3.5
|
||||
Reference in New Issue
Block a user