refactor: rename package fabledassistant -> scribe (code-only)
Renames src/fabledassistant -> src/scribe and all imports, plus the default DB name and DB user/password (fabled -> scribe) in config + compose. 952 refs / 154 files. Reverses the old 'internal name stays fabledassistant' convention. Code-only: live databases are still physically named 'fabledassistant'. Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git host (fabledsword), MCP (fabled-git) and the image name (fabledscribe) are intentionally unchanged. ruff check src/ clean locally; CI (typecheck + pytest) is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from quart import Blueprint, Response, g, jsonify, request
|
||||
|
||||
from scribe.auth import admin_required, login_required, get_current_user_id
|
||||
from scribe.services.auth import (
|
||||
create_invitation,
|
||||
delete_user,
|
||||
is_registration_open,
|
||||
list_pending_invitations,
|
||||
list_users,
|
||||
revoke_invitation,
|
||||
set_registration_open,
|
||||
)
|
||||
from scribe.services.backup import (
|
||||
export_full_backup,
|
||||
export_user_backup,
|
||||
restore_full_backup,
|
||||
)
|
||||
from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
|
||||
from scribe.services.logging import get_logs, get_log_stats, log_audit
|
||||
from scribe.services.notifications import send_invitation_email
|
||||
from scribe.services.settings import set_setting, set_settings_batch
|
||||
|
||||
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
|
||||
|
||||
|
||||
@admin_bp.route("/backup", methods=["GET"])
|
||||
@login_required
|
||||
async def backup():
|
||||
uid = get_current_user_id()
|
||||
scope = request.args.get("scope", "user")
|
||||
|
||||
if scope == "full":
|
||||
# Full backup requires admin
|
||||
if g.user.role != "admin":
|
||||
return jsonify({"error": "Admin access required for full backup"}), 403
|
||||
data = await export_full_backup()
|
||||
else:
|
||||
data = await export_user_backup(uid)
|
||||
|
||||
await log_audit("backup", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"scope": scope})
|
||||
return Response(
|
||||
json.dumps(data, indent=2, default=str),
|
||||
content_type="application/json",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="fabled-backup-{scope}.json"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route("/restore", methods=["POST"])
|
||||
@admin_required
|
||||
async def restore():
|
||||
data = await request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "No backup data provided"}), 400
|
||||
|
||||
stats = await restore_full_backup(data)
|
||||
uid = get_current_user_id()
|
||||
await log_audit("restore", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"stats": stats})
|
||||
return jsonify({"status": "ok", "stats": stats})
|
||||
|
||||
|
||||
@admin_bp.route("/users", methods=["GET"])
|
||||
@admin_required
|
||||
async def get_users():
|
||||
users = await list_users()
|
||||
return jsonify({"users": [u.to_dict() for u in users]})
|
||||
|
||||
|
||||
@admin_bp.route("/users/<int:user_id>", methods=["DELETE"])
|
||||
@admin_required
|
||||
async def remove_user(user_id: int):
|
||||
current_uid = get_current_user_id()
|
||||
if user_id == current_uid:
|
||||
return jsonify({"error": "Cannot delete your own account"}), 400
|
||||
|
||||
deleted = await delete_user(user_id)
|
||||
if not deleted:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
await log_audit("user_delete", user_id=current_uid, username=g.user.username, ip_address=request.remote_addr, details={"deleted_user_id": user_id})
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@admin_bp.route("/registration", methods=["GET"])
|
||||
@admin_required
|
||||
async def get_registration():
|
||||
open_status = await is_registration_open()
|
||||
return jsonify({"open": open_status})
|
||||
|
||||
|
||||
@admin_bp.route("/registration", methods=["PUT"])
|
||||
@admin_required
|
||||
async def toggle_registration():
|
||||
data = await request.get_json()
|
||||
open_val = data.get("open")
|
||||
if open_val is None:
|
||||
return jsonify({"error": "Missing 'open' field"}), 400
|
||||
|
||||
uid = get_current_user_id()
|
||||
await set_registration_open(uid, bool(open_val))
|
||||
await log_audit("registration_toggle", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"open": bool(open_val)})
|
||||
return jsonify({"status": "ok", "open": bool(open_val)})
|
||||
|
||||
|
||||
@admin_bp.route("/smtp", methods=["GET"])
|
||||
@admin_required
|
||||
async def get_smtp():
|
||||
config = await get_smtp_config()
|
||||
# Mask password
|
||||
if config.get("smtp_password"):
|
||||
config["smtp_password"] = "********"
|
||||
return jsonify(config)
|
||||
|
||||
|
||||
@admin_bp.route("/smtp", methods=["PUT"])
|
||||
@admin_required
|
||||
async def update_smtp():
|
||||
data = await request.get_json()
|
||||
uid = get_current_user_id()
|
||||
|
||||
settings_to_save = {}
|
||||
for key in SMTP_SETTING_KEYS:
|
||||
if key in data:
|
||||
# Skip password if it's the mask placeholder
|
||||
if key == "smtp_password" and data[key] == "********":
|
||||
continue
|
||||
settings_to_save[key] = str(data[key])
|
||||
|
||||
if settings_to_save:
|
||||
await set_settings_batch(uid, settings_to_save)
|
||||
await log_audit("smtp_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@admin_bp.route("/smtp/test", methods=["POST"])
|
||||
@admin_required
|
||||
async def test_smtp():
|
||||
data = await request.get_json()
|
||||
recipient = (data.get("recipient") or "").strip()
|
||||
if not recipient:
|
||||
return jsonify({"error": "Recipient email is required"}), 400
|
||||
|
||||
uid = get_current_user_id()
|
||||
try:
|
||||
await send_test_email(recipient)
|
||||
await log_audit("smtp_test", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"recipient": recipient})
|
||||
return jsonify({"status": "ok"})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@admin_bp.route("/logs", methods=["GET"])
|
||||
@admin_required
|
||||
async def list_logs():
|
||||
category = request.args.get("category")
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
search = request.args.get("search")
|
||||
date_from = request.args.get("date_from")
|
||||
date_to = request.args.get("date_to")
|
||||
limit = request.args.get("limit", 50, type=int)
|
||||
offset = request.args.get("offset", 0, type=int)
|
||||
|
||||
logs, total = await get_logs(
|
||||
category=category,
|
||||
user_id=user_id,
|
||||
search=search,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=min(limit, 200),
|
||||
offset=offset,
|
||||
)
|
||||
return jsonify({"logs": logs, "total": total})
|
||||
|
||||
|
||||
@admin_bp.route("/logs/stats", methods=["GET"])
|
||||
@admin_required
|
||||
async def log_stats():
|
||||
stats = await get_log_stats()
|
||||
return jsonify(stats)
|
||||
|
||||
|
||||
@admin_bp.route("/base-url", methods=["GET"])
|
||||
@admin_required
|
||||
async def get_base_url_setting():
|
||||
base_url = await get_base_url()
|
||||
return jsonify({"base_url": base_url})
|
||||
|
||||
|
||||
@admin_bp.route("/base-url", methods=["PUT"])
|
||||
@admin_required
|
||||
async def update_base_url():
|
||||
data = await request.get_json()
|
||||
url = (data.get("base_url") or "").strip().rstrip("/")
|
||||
if url:
|
||||
scheme = url.split("://")[0].lower() if "://" in url else ""
|
||||
if scheme not in ("http", "https"):
|
||||
return jsonify({"error": "Base URL must use http or https"}), 400
|
||||
uid = get_current_user_id()
|
||||
await set_setting(uid, "base_url", url)
|
||||
await log_audit("base_url_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"base_url": url})
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@admin_bp.route("/invitations", methods=["POST"])
|
||||
@admin_required
|
||||
async def create_invite():
|
||||
data = await request.get_json()
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
if not email:
|
||||
return jsonify({"error": "Email is required"}), 400
|
||||
|
||||
if not await is_smtp_configured():
|
||||
return jsonify({"error": "SMTP is not configured. Configure email settings first."}), 400
|
||||
|
||||
uid = get_current_user_id()
|
||||
raw_token = await create_invitation(email, uid)
|
||||
base_url = await get_base_url()
|
||||
invite_url = f"{base_url}/register-invite?token={raw_token}"
|
||||
asyncio.create_task(send_invitation_email(email, invite_url, g.user.username))
|
||||
await log_audit(
|
||||
"invitation_created",
|
||||
user_id=uid,
|
||||
username=g.user.username,
|
||||
ip_address=request.remote_addr,
|
||||
details={"invited_email": email},
|
||||
)
|
||||
return jsonify({"status": "ok"}), 201
|
||||
|
||||
|
||||
@admin_bp.route("/invitations", methods=["GET"])
|
||||
@admin_required
|
||||
async def get_invitations():
|
||||
invitations = await list_pending_invitations()
|
||||
return jsonify({
|
||||
"invitations": [
|
||||
{
|
||||
"id": inv.id,
|
||||
"email": inv.email,
|
||||
"created_at": inv.created_at.isoformat(),
|
||||
"expires_at": inv.expires_at.isoformat(),
|
||||
}
|
||||
for inv in invitations
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@admin_bp.route("/invitations/<int:invitation_id>", methods=["DELETE"])
|
||||
@admin_required
|
||||
async def delete_invitation(invitation_id: int):
|
||||
uid = get_current_user_id()
|
||||
revoked = await revoke_invitation(invitation_id)
|
||||
if not revoked:
|
||||
return jsonify({"error": "Invitation not found or already used"}), 404
|
||||
await log_audit(
|
||||
"invitation_revoked",
|
||||
user_id=uid,
|
||||
username=g.user.username,
|
||||
ip_address=request.remote_addr,
|
||||
details={"invitation_id": invitation_id},
|
||||
)
|
||||
return jsonify({"status": "ok"})
|
||||
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
from quart import Blueprint, jsonify
|
||||
|
||||
api = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
@api.route("/health")
|
||||
async def health():
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@api.route("/version")
|
||||
async def version():
|
||||
return jsonify({"version": os.environ.get("APP_VERSION", "dev")})
|
||||
@@ -0,0 +1,42 @@
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import login_required, get_current_user_id
|
||||
from scribe.services.api_keys import create_api_key, list_api_keys, revoke_api_key
|
||||
|
||||
api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys")
|
||||
|
||||
|
||||
@api_keys_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def list_keys_route():
|
||||
uid = get_current_user_id()
|
||||
keys = await list_api_keys(uid)
|
||||
return jsonify({"api_keys": keys})
|
||||
|
||||
|
||||
@api_keys_bp.route("", methods=["POST"])
|
||||
@login_required
|
||||
async def create_key_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
scope = (data.get("scope") or "").strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
if scope not in ("read", "write"):
|
||||
return jsonify({"error": "scope must be 'read' or 'write'"}), 400
|
||||
|
||||
full_key, key_dict = await create_api_key(uid, name=name, scope=scope)
|
||||
# Return the full key ONCE — it is never retrievable again
|
||||
return jsonify({"key": full_key, "api_key": key_dict}), 201
|
||||
|
||||
|
||||
@api_keys_bp.route("/<int:key_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def revoke_key_route(key_id: int):
|
||||
uid = get_current_user_id()
|
||||
deleted = await revoke_api_key(uid, key_id)
|
||||
if not deleted:
|
||||
return jsonify({"error": "API key not found"}), 404
|
||||
return "", 204
|
||||
@@ -0,0 +1,375 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from quart import Blueprint, g, jsonify, redirect, request, session
|
||||
|
||||
from scribe.auth import login_required, get_current_user_id
|
||||
from scribe.config import Config
|
||||
from scribe.rate_limit import is_rate_limited
|
||||
from scribe.services.auth import (
|
||||
authenticate,
|
||||
change_password,
|
||||
create_password_reset_token,
|
||||
create_user,
|
||||
get_user_by_email,
|
||||
get_user_by_id,
|
||||
get_user_by_username,
|
||||
get_user_count,
|
||||
invalidate_other_sessions,
|
||||
is_registration_open,
|
||||
register_with_invitation,
|
||||
reset_password_with_token,
|
||||
update_user_email,
|
||||
validate_invitation_token,
|
||||
verify_password,
|
||||
)
|
||||
from scribe.services.logging import log_audit
|
||||
from scribe.services.notifications import (
|
||||
notify_security_event,
|
||||
send_password_reset_email,
|
||||
send_password_reset_success_email,
|
||||
)
|
||||
from scribe.services.email import get_base_url, is_smtp_configured
|
||||
from scribe.services.oauth import (
|
||||
build_auth_url,
|
||||
exchange_code,
|
||||
get_userinfo,
|
||||
find_or_create_oauth_user,
|
||||
)
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||
|
||||
|
||||
def _client_ip() -> str:
|
||||
if Config.TRUST_PROXY_HEADERS:
|
||||
for header in ("X-Forwarded-For", "X-Real-IP"):
|
||||
val = request.headers.get(header, "").split(",")[0].strip()
|
||||
if val:
|
||||
return val
|
||||
return request.remote_addr or ""
|
||||
|
||||
|
||||
@auth_bp.route("/register", methods=["POST"])
|
||||
async def register():
|
||||
if not Config.LOCAL_AUTH_ENABLED:
|
||||
return jsonify({"error": "Local authentication is disabled. Please use SSO."}), 403
|
||||
if await is_rate_limited(f"register:{_client_ip()}", max_requests=5, window_seconds=300):
|
||||
return jsonify({"error": "Too many registration attempts. Try again later."}), 429
|
||||
if not await is_registration_open():
|
||||
return jsonify({"error": "Registration is closed"}), 403
|
||||
|
||||
data = await request.get_json()
|
||||
username = (data.get("username") or "").strip()
|
||||
password = data.get("password") or ""
|
||||
email = (data.get("email") or "").strip() or None
|
||||
|
||||
if not username:
|
||||
return jsonify({"error": "Username is required"}), 400
|
||||
if len(password) < 8:
|
||||
return jsonify({"error": "Password must be at least 8 characters"}), 400
|
||||
|
||||
try:
|
||||
user = await create_user(username, password, email)
|
||||
except Exception:
|
||||
return jsonify({"error": "Username already taken"}), 409
|
||||
|
||||
session["user_id"] = user.id
|
||||
session["session_version"] = user.session_version
|
||||
await log_audit("register", user_id=user.id, username=user.username, ip_address=_client_ip())
|
||||
return jsonify(user.to_dict()), 201
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["POST"])
|
||||
async def login():
|
||||
if not Config.LOCAL_AUTH_ENABLED:
|
||||
return jsonify({"error": "Local authentication is disabled. Please use SSO."}), 403
|
||||
if await is_rate_limited(f"login:{_client_ip()}", max_requests=10, window_seconds=60):
|
||||
return jsonify({"error": "Too many login attempts. Try again later."}), 429
|
||||
data = await request.get_json()
|
||||
username = (data.get("username") or "").strip()
|
||||
password = data.get("password") or ""
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({"error": "Username and password are required"}), 400
|
||||
|
||||
user = await authenticate(username, password)
|
||||
if not user:
|
||||
await log_audit("login_failed", username=username, ip_address=_client_ip())
|
||||
# Try to notify the actual user about the failed attempt
|
||||
target_user = await get_user_by_username(username)
|
||||
if target_user:
|
||||
asyncio.create_task(notify_security_event(
|
||||
target_user.id, "login_failed",
|
||||
{"ip_address": _client_ip(), "username": username},
|
||||
))
|
||||
return jsonify({"error": "Invalid username or password"}), 401
|
||||
|
||||
session["user_id"] = user.id
|
||||
session["session_version"] = user.session_version
|
||||
await log_audit("login", user_id=user.id, username=user.username, ip_address=_client_ip())
|
||||
asyncio.create_task(notify_security_event(
|
||||
user.id, "login",
|
||||
{"ip_address": _client_ip()},
|
||||
))
|
||||
return jsonify(user.to_dict())
|
||||
|
||||
|
||||
@auth_bp.route("/logout", methods=["POST"])
|
||||
async def logout():
|
||||
uid = session.get("user_id")
|
||||
if uid:
|
||||
user = await get_user_by_id(uid)
|
||||
await log_audit("logout", user_id=uid, username=user.username if user else None, ip_address=_client_ip())
|
||||
asyncio.create_task(notify_security_event(
|
||||
uid, "logout",
|
||||
{"ip_address": _client_ip()},
|
||||
))
|
||||
session.clear()
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@auth_bp.route("/me", methods=["GET"])
|
||||
@login_required
|
||||
async def me():
|
||||
user = await get_user_by_id(get_current_user_id())
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
return jsonify(user.to_dict())
|
||||
|
||||
|
||||
@auth_bp.route("/password", methods=["PUT"])
|
||||
@login_required
|
||||
async def update_password():
|
||||
data = await request.get_json()
|
||||
current = data.get("current_password") or ""
|
||||
new_pw = data.get("new_password") or ""
|
||||
|
||||
if not current:
|
||||
return jsonify({"error": "Current password is required"}), 400
|
||||
if len(new_pw) < 8:
|
||||
return jsonify({"error": "New password must be at least 8 characters"}), 400
|
||||
|
||||
uid = get_current_user_id()
|
||||
new_version = await change_password(uid, current, new_pw)
|
||||
if new_version is None:
|
||||
return jsonify({"error": "Current password is incorrect"}), 403
|
||||
|
||||
# Keep the current session alive with the new version (other sessions are now invalidated)
|
||||
session["session_version"] = new_version
|
||||
await log_audit("password_change", user_id=uid, username=g.user.username, ip_address=_client_ip())
|
||||
asyncio.create_task(notify_security_event(
|
||||
uid, "password_change",
|
||||
{"ip_address": _client_ip()},
|
||||
))
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@auth_bp.route("/invalidate-sessions", methods=["POST"])
|
||||
@login_required
|
||||
async def invalidate_sessions_route():
|
||||
"""Invalidate all other active sessions by bumping the session version.
|
||||
|
||||
The current session is kept alive. Useful after an SSO/OAuth password change
|
||||
where the app has no visibility into credential rotation.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
new_version = await invalidate_other_sessions(uid)
|
||||
session["session_version"] = new_version
|
||||
await log_audit("sessions_invalidated", user_id=uid, username=g.user.username, ip_address=_client_ip())
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@auth_bp.route("/email", methods=["PUT"])
|
||||
@login_required
|
||||
async def update_email():
|
||||
data = await request.get_json()
|
||||
new_email = (data.get("email") or "").strip().lower() or None
|
||||
password = data.get("password") or ""
|
||||
|
||||
uid = get_current_user_id()
|
||||
user = await get_user_by_id(uid)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
# Require password confirmation only for local-auth users
|
||||
if user.password_hash is not None:
|
||||
if not password:
|
||||
return jsonify({"error": "Password is required to change your email"}), 400
|
||||
if not verify_password(password, user.password_hash):
|
||||
return jsonify({"error": "Incorrect password"}), 403
|
||||
|
||||
# Check the new email isn't taken by a different account
|
||||
if new_email:
|
||||
existing = await get_user_by_email(new_email)
|
||||
if existing and existing.id != uid:
|
||||
return jsonify({"error": "Email already in use by another account"}), 409
|
||||
|
||||
await update_user_email(uid, new_email)
|
||||
await log_audit("email_change", user_id=uid, username=user.username, ip_address=_client_ip())
|
||||
updated = await get_user_by_id(uid)
|
||||
return jsonify(updated.to_dict())
|
||||
|
||||
|
||||
@auth_bp.route("/forgot-password", methods=["POST"])
|
||||
async def forgot_password():
|
||||
if await is_rate_limited(f"forgot:{_client_ip()}", max_requests=5, window_seconds=300):
|
||||
return jsonify({"status": "ok"})
|
||||
data = await request.get_json()
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
|
||||
if not email:
|
||||
return jsonify({"error": "Email is required"}), 400
|
||||
|
||||
# Always return success to prevent email enumeration
|
||||
user = await get_user_by_email(email)
|
||||
if user and await is_smtp_configured():
|
||||
raw_token = await create_password_reset_token(user.id)
|
||||
base_url = await get_base_url()
|
||||
reset_url = f"{base_url}/reset-password?token={raw_token}"
|
||||
asyncio.create_task(send_password_reset_email(user.email, reset_url))
|
||||
await log_audit(
|
||||
"password_reset_requested",
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
ip_address=_client_ip(),
|
||||
)
|
||||
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@auth_bp.route("/reset-password", methods=["POST"])
|
||||
async def reset_password():
|
||||
if await is_rate_limited(f"reset:{_client_ip()}", max_requests=10, window_seconds=60):
|
||||
return jsonify({"error": "Too many attempts. Try again later."}), 429
|
||||
data = await request.get_json()
|
||||
token = (data.get("token") or "").strip()
|
||||
new_password = data.get("new_password") or ""
|
||||
|
||||
if not token:
|
||||
return jsonify({"error": "Reset token is required"}), 400
|
||||
if len(new_password) < 8:
|
||||
return jsonify({"error": "Password must be at least 8 characters"}), 400
|
||||
|
||||
user_id = await reset_password_with_token(token, new_password)
|
||||
if user_id is None:
|
||||
return jsonify({"error": "Invalid or expired reset link"}), 400
|
||||
|
||||
user = await get_user_by_id(user_id)
|
||||
if user:
|
||||
await log_audit(
|
||||
"password_reset_completed",
|
||||
user_id=user.id, username=user.username, ip_address=_client_ip(),
|
||||
)
|
||||
if user.email:
|
||||
asyncio.create_task(send_password_reset_success_email(user.email))
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@auth_bp.route("/invitation/<token>", methods=["GET"])
|
||||
async def check_invitation(token: str):
|
||||
invitation = await validate_invitation_token(token)
|
||||
if not invitation:
|
||||
return jsonify({"valid": False})
|
||||
return jsonify({"valid": True, "email": invitation.email})
|
||||
|
||||
|
||||
@auth_bp.route("/register-with-invite", methods=["POST"])
|
||||
async def register_with_invite():
|
||||
data = await request.get_json()
|
||||
raw_token = (data.get("token") or "").strip()
|
||||
username = (data.get("username") or "").strip()
|
||||
password = data.get("password") or ""
|
||||
|
||||
if not raw_token:
|
||||
return jsonify({"error": "Invitation token is required"}), 400
|
||||
if not username:
|
||||
return jsonify({"error": "Username is required"}), 400
|
||||
if len(password) < 8:
|
||||
return jsonify({"error": "Password must be at least 8 characters"}), 400
|
||||
|
||||
try:
|
||||
user = await register_with_invitation(raw_token, username, password)
|
||||
except Exception:
|
||||
return jsonify({"error": "Username already taken"}), 409
|
||||
|
||||
if not user:
|
||||
return jsonify({"error": "Invalid or expired invitation"}), 400
|
||||
|
||||
session["user_id"] = user.id
|
||||
session["session_version"] = user.session_version
|
||||
await log_audit(
|
||||
"register_with_invite",
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
ip_address=_client_ip(),
|
||||
)
|
||||
return jsonify(user.to_dict()), 201
|
||||
|
||||
|
||||
@auth_bp.route("/status", methods=["GET"])
|
||||
async def status():
|
||||
count = await get_user_count()
|
||||
reg_open = await is_registration_open()
|
||||
return jsonify({
|
||||
"has_users": count > 0,
|
||||
"registration_open": reg_open,
|
||||
"oauth_enabled": Config.oidc_enabled(),
|
||||
"local_auth_enabled": Config.LOCAL_AUTH_ENABLED,
|
||||
})
|
||||
|
||||
|
||||
@auth_bp.route("/oauth/login", methods=["GET"])
|
||||
async def oauth_login():
|
||||
if not Config.oidc_enabled():
|
||||
return jsonify({"error": "SSO is not configured"}), 404
|
||||
|
||||
state = secrets.token_hex(32)
|
||||
code_verifier = secrets.token_urlsafe(64)
|
||||
|
||||
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
||||
|
||||
session["oauth_state"] = state
|
||||
session["oauth_code_verifier"] = code_verifier
|
||||
|
||||
url = await build_auth_url(state, code_challenge)
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@auth_bp.route("/oauth/callback", methods=["GET"])
|
||||
async def oauth_callback():
|
||||
error = request.args.get("error")
|
||||
code = request.args.get("code")
|
||||
state = request.args.get("state")
|
||||
|
||||
stored_state = session.pop("oauth_state", None)
|
||||
code_verifier = session.pop("oauth_code_verifier", None)
|
||||
|
||||
if error or not code or state != stored_state:
|
||||
return redirect("/login?error=oauth")
|
||||
|
||||
redirect_uri = Config.BASE_URL.rstrip("/") + "/api/auth/oauth/callback"
|
||||
try:
|
||||
token_response = await exchange_code(code, redirect_uri, code_verifier)
|
||||
claims = await get_userinfo(token_response["access_token"])
|
||||
except Exception:
|
||||
return redirect("/login?error=oauth")
|
||||
|
||||
sub = claims.get("sub", "")
|
||||
# Only trust the email claim for account linking if the provider has verified it.
|
||||
# An unverified email could be used to hijack an existing local account.
|
||||
email_verified = claims.get("email_verified", False)
|
||||
email = claims.get("email", "") if email_verified else ""
|
||||
preferred_username = claims.get("preferred_username", "")
|
||||
|
||||
if not sub:
|
||||
return redirect("/login?error=oauth")
|
||||
|
||||
user = await find_or_create_oauth_user(sub, email, preferred_username)
|
||||
session["user_id"] = user.id
|
||||
session["session_version"] = user.session_version
|
||||
await log_audit("oauth_login", user_id=user.id, username=user.username, ip_address=_client_ip())
|
||||
return redirect("/")
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Dashboard REST endpoint — the aggregated landing payload."""
|
||||
from quart import Blueprint, g, jsonify
|
||||
|
||||
from scribe.auth import login_required
|
||||
from scribe.services.dashboard import build_dashboard
|
||||
|
||||
dashboard_bp = Blueprint("dashboard", __name__, url_prefix="/api/dashboard")
|
||||
|
||||
|
||||
@dashboard_bp.get("")
|
||||
@login_required
|
||||
async def get_dashboard():
|
||||
return jsonify(await build_dashboard(g.user.id))
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Calendar events REST API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from scribe.auth import login_required
|
||||
import scribe.services.events as events_svc
|
||||
|
||||
events_bp = Blueprint("events", __name__, url_prefix="/api/events")
|
||||
|
||||
|
||||
def _parse_dt(value: str) -> datetime:
|
||||
"""Parse ISO 8601 datetime string, ensuring UTC-awareness."""
|
||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def _get_current_user_id() -> int:
|
||||
return g.user.id
|
||||
|
||||
|
||||
@events_bp.get("")
|
||||
@login_required
|
||||
async def list_events():
|
||||
date_from_str = request.args.get("from")
|
||||
date_to_str = request.args.get("to")
|
||||
if not date_from_str or not date_to_str:
|
||||
return jsonify({"error": "from and to query params are required"}), 400
|
||||
try:
|
||||
date_from = _parse_dt(date_from_str)
|
||||
date_to = _parse_dt(date_to_str)
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid datetime format"}), 400
|
||||
events = await events_svc.list_events(
|
||||
user_id=_get_current_user_id(),
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
return jsonify(events)
|
||||
|
||||
|
||||
@events_bp.post("")
|
||||
@login_required
|
||||
async def create_event():
|
||||
data = await request.get_json() or {}
|
||||
if not data.get("title") or not data.get("start_dt"):
|
||||
return jsonify({"error": "title and start_dt are required"}), 400
|
||||
try:
|
||||
start_dt = _parse_dt(data["start_dt"])
|
||||
end_dt = _parse_dt(data["end_dt"]) if data.get("end_dt") else None
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid datetime format"}), 400
|
||||
try:
|
||||
event = await events_svc.create_event(
|
||||
user_id=_get_current_user_id(),
|
||||
title=data["title"],
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
duration_minutes=data.get("duration_minutes"),
|
||||
all_day=data.get("all_day", False),
|
||||
description=data.get("description", ""),
|
||||
location=data.get("location", ""),
|
||||
color=data.get("color", ""),
|
||||
recurrence=data.get("recurrence"),
|
||||
project_id=data.get("project_id"),
|
||||
reminder_minutes=data.get("reminder_minutes"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify(event.to_dict()), 201
|
||||
|
||||
|
||||
@events_bp.get("/<int:event_id>")
|
||||
@login_required
|
||||
async def get_event(event_id: int):
|
||||
event = await events_svc.get_event(
|
||||
user_id=_get_current_user_id(),
|
||||
event_id=event_id,
|
||||
)
|
||||
if event is None:
|
||||
return jsonify({"error": "Event not found"}), 404
|
||||
return jsonify(event.to_dict())
|
||||
|
||||
|
||||
@events_bp.patch("/<int:event_id>")
|
||||
@login_required
|
||||
async def update_event(event_id: int):
|
||||
data = await request.get_json() or {}
|
||||
fields: dict = {}
|
||||
for str_field in ("title", "description", "location", "color", "recurrence"):
|
||||
if str_field in data:
|
||||
fields[str_field] = data[str_field]
|
||||
for bool_field in ("all_day",):
|
||||
if bool_field in data:
|
||||
fields[bool_field] = data[bool_field]
|
||||
for int_field in ("project_id", "reminder_minutes", "duration_minutes"):
|
||||
if int_field in data:
|
||||
fields[int_field] = data[int_field]
|
||||
for dt_field in ("start_dt", "end_dt"):
|
||||
if dt_field in data:
|
||||
if data[dt_field] is None:
|
||||
# Explicit null clears the field (e.g. removing end_dt)
|
||||
fields[dt_field] = None
|
||||
elif data[dt_field]:
|
||||
try:
|
||||
fields[dt_field] = _parse_dt(data[dt_field])
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
|
||||
try:
|
||||
event = await events_svc.update_event(
|
||||
user_id=_get_current_user_id(),
|
||||
event_id=event_id,
|
||||
**fields,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if event is None:
|
||||
return jsonify({"error": "Event not found"}), 404
|
||||
return jsonify(event.to_dict())
|
||||
|
||||
|
||||
@events_bp.delete("/<int:event_id>")
|
||||
@login_required
|
||||
async def delete_event(event_id: int):
|
||||
from scribe.services.trash import delete as trash_delete
|
||||
batch = await trash_delete(_get_current_user_id(), "event", event_id)
|
||||
if batch is None:
|
||||
return jsonify({"error": "Event not found"}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@events_bp.post("/sync")
|
||||
@login_required
|
||||
async def sync_caldav():
|
||||
"""Trigger a CalDAV pull sync for the current user."""
|
||||
from scribe.services.caldav_sync import sync_user_events
|
||||
result = await sync_user_events(user_id=_get_current_user_id())
|
||||
return jsonify(result)
|
||||
@@ -0,0 +1,117 @@
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from quart import Blueprint, Response, request
|
||||
|
||||
from scribe.auth import login_required, get_current_user_id
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
from sqlalchemy import select
|
||||
|
||||
export_bp = Blueprint("export", __name__)
|
||||
|
||||
|
||||
def _safe_filename(title: str) -> str:
|
||||
name = re.sub(r"[^\w\s-]", "", title or "untitled").strip()
|
||||
name = re.sub(r"[\s]+", "_", name) or "untitled"
|
||||
return name[:80]
|
||||
|
||||
|
||||
def _note_frontmatter(note: Note) -> str:
|
||||
lines = ["---"]
|
||||
lines.append(f"title: {json.dumps(note.title or '')}")
|
||||
lines.append(f"type: {'task' if note.is_task else 'note'}")
|
||||
if note.tags:
|
||||
lines.append(f"tags: [{', '.join(note.tags)}]")
|
||||
if note.is_task:
|
||||
lines.append(f"status: {note.status or 'todo'}")
|
||||
lines.append(f"priority: {note.priority or 'none'}")
|
||||
if note.due_date:
|
||||
lines.append(f"due_date: {note.due_date}")
|
||||
if note.project_id:
|
||||
lines.append(f"project_id: {note.project_id}")
|
||||
lines.append(f"created_at: {note.created_at.isoformat() if note.created_at else ''}")
|
||||
lines.append(f"updated_at: {note.updated_at.isoformat() if note.updated_at else ''}")
|
||||
lines.append("---")
|
||||
return "\n".join(lines) + "\n\n"
|
||||
|
||||
|
||||
async def _fetch_notes(uid: int):
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(Note)
|
||||
.where(Note.user_id == uid)
|
||||
.order_by(Note.updated_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@export_bp.get("/api/export")
|
||||
@login_required
|
||||
async def export_data():
|
||||
uid = get_current_user_id()
|
||||
fmt = request.args.get("format", "markdown")
|
||||
|
||||
notes = await _fetch_notes(uid)
|
||||
|
||||
if fmt == "json":
|
||||
payload = [
|
||||
{
|
||||
"id": n.id,
|
||||
"title": n.title,
|
||||
"body": n.body,
|
||||
"is_task": n.is_task,
|
||||
"status": n.status,
|
||||
"priority": n.priority,
|
||||
"tags": n.tags or [],
|
||||
"due_date": str(n.due_date) if n.due_date else None,
|
||||
"project_id": n.project_id,
|
||||
"milestone_id": n.milestone_id,
|
||||
"parent_id": n.parent_id,
|
||||
"created_at": n.created_at.isoformat() if n.created_at else None,
|
||||
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
|
||||
}
|
||||
for n in notes
|
||||
]
|
||||
data = json.dumps(payload, indent=2, ensure_ascii=False).encode("utf-8")
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
return Response(
|
||||
data,
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Disposition": f'attachment; filename="scribe-{stamp}.json"',
|
||||
},
|
||||
)
|
||||
|
||||
# Markdown ZIP
|
||||
buf = io.BytesIO()
|
||||
used_names: dict[str, int] = {}
|
||||
|
||||
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
index_rows = []
|
||||
for note in notes:
|
||||
base = _safe_filename(note.title)
|
||||
count = used_names.get(base, 0)
|
||||
used_names[base] = count + 1
|
||||
fname = f"{base}_{count}.md" if count else f"{base}.md"
|
||||
|
||||
content = _note_frontmatter(note) + (note.body or "")
|
||||
zf.writestr(fname, content.encode("utf-8"))
|
||||
index_rows.append({"file": fname, "title": note.title, "id": note.id})
|
||||
|
||||
zf.writestr("index.json", json.dumps(index_rows, indent=2, ensure_ascii=False))
|
||||
|
||||
buf.seek(0)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
return Response(
|
||||
buf.read(),
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": f'attachment; filename="scribe-{stamp}.zip"',
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
import asyncio
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.services import groups as group_svc
|
||||
from scribe.services.notifications import notify_group_added
|
||||
|
||||
groups_bp = Blueprint("groups", __name__, url_prefix="/api/groups")
|
||||
|
||||
|
||||
@groups_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def list_groups():
|
||||
uid = get_current_user_id()
|
||||
return jsonify({"groups": await group_svc.list_groups(uid)})
|
||||
|
||||
|
||||
@groups_bp.route("", methods=["POST"])
|
||||
@login_required
|
||||
async def create_group():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"error": "Name is required"}), 400
|
||||
try:
|
||||
group = await group_svc.create_group(uid, name, data.get("description"))
|
||||
except Exception:
|
||||
return jsonify({"error": "Group name already taken"}), 409
|
||||
return jsonify(group.to_dict()), 201
|
||||
|
||||
|
||||
@groups_bp.route("/<int:group_id>", methods=["GET"])
|
||||
@login_required
|
||||
async def get_group(group_id: int):
|
||||
group = await group_svc.get_group(group_id)
|
||||
if not group:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
members = await group_svc.list_members(group_id)
|
||||
return jsonify({**group.to_dict(), "members": members})
|
||||
|
||||
|
||||
@groups_bp.route("/<int:group_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_group(group_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
updates = {k: v for k, v in data.items() if k in ("name", "description")}
|
||||
group = await group_svc.update_group(uid, group_id, g.user.role == "admin", **updates)
|
||||
if not group:
|
||||
return jsonify({"error": "Not found or forbidden"}), 404
|
||||
return jsonify(group.to_dict())
|
||||
|
||||
|
||||
@groups_bp.route("/<int:group_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def delete_group(group_id: int):
|
||||
uid = get_current_user_id()
|
||||
deleted = await group_svc.delete_group(uid, group_id, g.user.role == "admin")
|
||||
if not deleted:
|
||||
return jsonify({"error": "Not found or forbidden"}), 404
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@groups_bp.route("/<int:group_id>/members", methods=["GET"])
|
||||
@login_required
|
||||
async def list_members(group_id: int):
|
||||
members = await group_svc.list_members(group_id)
|
||||
return jsonify({"members": members})
|
||||
|
||||
|
||||
@groups_bp.route("/<int:group_id>/members", methods=["POST"])
|
||||
@login_required
|
||||
async def add_member(group_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
target_uid = data.get("user_id")
|
||||
role = data.get("role", "member")
|
||||
if not target_uid:
|
||||
return jsonify({"error": "user_id required"}), 400
|
||||
if role not in ("owner", "member"):
|
||||
return jsonify({"error": "role must be owner or member"}), 400
|
||||
membership = await group_svc.add_member(uid, group_id, target_uid, role, g.user.role == "admin")
|
||||
if not membership:
|
||||
return jsonify({"error": "Forbidden, group not found, or user already a member"}), 403
|
||||
asyncio.create_task(notify_group_added(group_id, role, uid, target_uid))
|
||||
return jsonify(membership.to_dict()), 201
|
||||
|
||||
|
||||
@groups_bp.route("/<int:group_id>/members/<int:target_uid>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_member(group_id: int, target_uid: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
role = data.get("role")
|
||||
if role not in ("owner", "member"):
|
||||
return jsonify({"error": "role must be owner or member"}), 400
|
||||
m = await group_svc.update_member_role(uid, group_id, target_uid, role, g.user.role == "admin")
|
||||
if not m:
|
||||
return jsonify({"error": "Not found or forbidden"}), 404
|
||||
return jsonify(m.to_dict())
|
||||
|
||||
|
||||
@groups_bp.route("/<int:group_id>/members/<int:target_uid>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def remove_member(group_id: int, target_uid: int):
|
||||
uid = get_current_user_id()
|
||||
removed = await group_svc.remove_member(uid, group_id, target_uid, g.user.role == "admin")
|
||||
if not removed:
|
||||
return jsonify({"error": "Not found or forbidden"}), 404
|
||||
return jsonify({"status": "ok"})
|
||||
@@ -0,0 +1,44 @@
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.services.notifications import (
|
||||
list_in_app_notifications,
|
||||
mark_all_notifications_read,
|
||||
mark_notification_read,
|
||||
unread_notification_count,
|
||||
)
|
||||
|
||||
notifications_bp = Blueprint("notifications", __name__, url_prefix="/api/notifications")
|
||||
|
||||
|
||||
@notifications_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def list_notifications():
|
||||
uid = get_current_user_id()
|
||||
all_flag = request.args.get("all", "false").lower() == "true"
|
||||
items = await list_in_app_notifications(uid, unread_only=not all_flag)
|
||||
return jsonify({"notifications": items})
|
||||
|
||||
|
||||
@notifications_bp.route("/count", methods=["GET"])
|
||||
@login_required
|
||||
async def get_count():
|
||||
uid = get_current_user_id()
|
||||
return jsonify({"count": await unread_notification_count(uid)})
|
||||
|
||||
|
||||
@notifications_bp.route("/<int:notif_id>/read", methods=["POST"])
|
||||
@login_required
|
||||
async def mark_read(notif_id: int):
|
||||
uid = get_current_user_id()
|
||||
if not await mark_notification_read(uid, notif_id):
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@notifications_bp.route("/read-all", methods=["POST"])
|
||||
@login_required
|
||||
async def mark_all_read():
|
||||
uid = get_current_user_id()
|
||||
count = await mark_all_notifications_read(uid)
|
||||
return jsonify({"status": "ok", "marked": count})
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Unified Knowledge endpoint — notes, people, places, lists in one queryable feed."""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.routes.utils import parse_pagination
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
|
||||
|
||||
_VALID_TYPES = {"note", "person", "place", "list", "task", "plan", "process"}
|
||||
_VALID_SORTS = {"modified", "created", "alpha", "type"}
|
||||
|
||||
|
||||
@knowledge_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def list_knowledge():
|
||||
"""Return paginated knowledge objects with optional filtering.
|
||||
|
||||
Query params:
|
||||
type — one of note|person|place|list (omit for all, excludes tasks)
|
||||
tags — comma-separated tag filter (AND logic)
|
||||
sort — modified|created|alpha|type (default: modified)
|
||||
q — search query (semantic when provided, keyword fallback)
|
||||
page — 1-based page number (default 1)
|
||||
per_page — items per page (default 24, max 100)
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
note_type = request.args.get("type", "").strip().lower() or None
|
||||
tags_raw = request.args.get("tags", "").strip()
|
||||
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else []
|
||||
sort = request.args.get("sort", "modified").strip().lower()
|
||||
q = request.args.get("q", "").strip() or None
|
||||
|
||||
if note_type and note_type not in _VALID_TYPES:
|
||||
return jsonify({"error": f"Invalid type. Must be one of: {', '.join(sorted(_VALID_TYPES))}"}), 400
|
||||
if sort not in _VALID_SORTS:
|
||||
sort = "modified"
|
||||
|
||||
limit, offset = parse_pagination(default_limit=24, max_limit=100)
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
|
||||
from scribe.services.knowledge import query_knowledge
|
||||
items, total = await query_knowledge(
|
||||
user_id=uid,
|
||||
note_type=note_type,
|
||||
tags=tags,
|
||||
sort=sort,
|
||||
q=q,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": limit,
|
||||
"pages": max(1, (total + limit - 1) // limit),
|
||||
})
|
||||
|
||||
|
||||
@knowledge_bp.route("/ids", methods=["GET"])
|
||||
@login_required
|
||||
async def list_knowledge_ids():
|
||||
"""Return note IDs only (cheap) for the two-tier pagination feed.
|
||||
|
||||
Same filter params as GET /api/knowledge.
|
||||
Additional params: limit (default 100, max 200), offset (default 0).
|
||||
Returns {ids, total, has_more}.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
note_type = request.args.get("type", "").strip().lower() or None
|
||||
tags_raw = request.args.get("tags", "").strip()
|
||||
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else []
|
||||
sort = request.args.get("sort", "modified").strip().lower()
|
||||
q = request.args.get("q", "").strip() or None
|
||||
if sort not in _VALID_SORTS:
|
||||
sort = "modified"
|
||||
try:
|
||||
limit = min(int(request.args.get("limit", 100)), 200)
|
||||
offset = max(0, int(request.args.get("offset", 0)))
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid limit or offset"}), 400
|
||||
|
||||
if note_type and note_type not in _VALID_TYPES:
|
||||
return jsonify({"error": "Invalid type"}), 400
|
||||
|
||||
from scribe.services.knowledge import query_knowledge_ids
|
||||
ids, total = await query_knowledge_ids(
|
||||
user_id=uid, note_type=note_type, tags=tags,
|
||||
sort=sort, q=q, limit=limit, offset=offset,
|
||||
)
|
||||
return jsonify({"ids": ids, "total": total, "has_more": (offset + len(ids)) < total})
|
||||
|
||||
|
||||
@knowledge_bp.route("/batch", methods=["GET"])
|
||||
@login_required
|
||||
async def get_knowledge_batch():
|
||||
"""Fetch full items for a comma-separated list of IDs (max 100).
|
||||
|
||||
Returns {items: [...]} in the order of the requested IDs.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
ids_raw = request.args.get("ids", "").strip()
|
||||
if not ids_raw:
|
||||
return jsonify({"items": []})
|
||||
try:
|
||||
ids = [int(x) for x in ids_raw.split(",") if x.strip()]
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid IDs"}), 400
|
||||
if len(ids) > 100:
|
||||
return jsonify({"error": "Too many IDs (max 100)"}), 400
|
||||
|
||||
from scribe.services.knowledge import get_knowledge_by_ids
|
||||
items = await get_knowledge_by_ids(uid, ids)
|
||||
return jsonify({"items": items})
|
||||
|
||||
|
||||
@knowledge_bp.route("/tags", methods=["GET"])
|
||||
@login_required
|
||||
async def list_knowledge_tags():
|
||||
"""Return all tags used across knowledge objects (excludes tasks)."""
|
||||
uid = get_current_user_id()
|
||||
note_type = request.args.get("type", "").strip().lower() or None
|
||||
|
||||
from scribe.services.knowledge import get_knowledge_tags
|
||||
tags = await get_knowledge_tags(uid, note_type=note_type)
|
||||
return jsonify({"tags": tags})
|
||||
|
||||
|
||||
@knowledge_bp.route("/counts", methods=["GET"])
|
||||
@login_required
|
||||
async def get_knowledge_counts():
|
||||
"""Return per-type counts — used by the sidebar to show item counts."""
|
||||
uid = get_current_user_id()
|
||||
tags_raw = request.args.get("tags", "").strip()
|
||||
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else None
|
||||
|
||||
from scribe.services.knowledge import get_knowledge_counts as _counts
|
||||
counts = await _counts(uid, tags=tags)
|
||||
return jsonify(counts)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Milestone routes nested under /api/projects/<project_id>/milestones."""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import login_required, get_current_user_id
|
||||
from scribe.routes.utils import not_found, parse_pagination
|
||||
from scribe.services.access import can_write_project
|
||||
from scribe.services.milestones import (
|
||||
create_milestone,
|
||||
delete_milestone,
|
||||
get_milestone_in_project,
|
||||
get_milestone_progress,
|
||||
list_milestones,
|
||||
update_milestone,
|
||||
)
|
||||
from scribe.services.notes import list_notes
|
||||
from scribe.services.projects import get_project_for_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
milestones_bp = Blueprint("milestones", __name__, url_prefix="/api/projects")
|
||||
|
||||
|
||||
async def _milestone_dict(m) -> dict:
|
||||
d = m.to_dict()
|
||||
d.update(await get_milestone_progress(m.id))
|
||||
return d
|
||||
|
||||
|
||||
@milestones_bp.route("/<int:project_id>/milestones", methods=["GET"])
|
||||
@login_required
|
||||
async def list_milestones_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
result = await get_project_for_user(uid, project_id)
|
||||
if result is None:
|
||||
return not_found("Project")
|
||||
project, _ = result
|
||||
# List milestones using the project owner's uid so ownership filter matches
|
||||
owner_uid = project.user_id or uid
|
||||
status = request.args.get("status")
|
||||
milestones = await list_milestones(owner_uid, project_id, status=status)
|
||||
return jsonify({"milestones": [await _milestone_dict(m) for m in milestones]})
|
||||
|
||||
|
||||
@milestones_bp.route("/<int:project_id>/milestones", methods=["POST"])
|
||||
@login_required
|
||||
async def create_milestone_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
if await get_project_for_user(uid, project_id) is None:
|
||||
return not_found("Project")
|
||||
if not await can_write_project(uid, project_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
data = await request.get_json()
|
||||
if not data.get("title"):
|
||||
return jsonify({"error": "title is required"}), 400
|
||||
status = data.get("status", "active")
|
||||
if status not in ("active", "done"):
|
||||
return jsonify({"error": "status must be 'active' or 'done'"}), 400
|
||||
milestone = await create_milestone(
|
||||
uid,
|
||||
project_id,
|
||||
title=data["title"],
|
||||
description=data.get("description"),
|
||||
order_index=data.get("order_index", 0),
|
||||
status=status,
|
||||
)
|
||||
return jsonify(await _milestone_dict(milestone)), 201
|
||||
|
||||
|
||||
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["GET"])
|
||||
@login_required
|
||||
async def get_milestone_route(project_id: int, milestone_id: int):
|
||||
uid = get_current_user_id()
|
||||
if await get_project_for_user(uid, project_id) is None:
|
||||
return not_found("Project")
|
||||
milestone = await get_milestone_in_project(project_id, milestone_id)
|
||||
if milestone is None:
|
||||
return not_found("Milestone")
|
||||
return jsonify(await _milestone_dict(milestone))
|
||||
|
||||
|
||||
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_milestone_route(project_id: int, milestone_id: int):
|
||||
uid = get_current_user_id()
|
||||
if await get_project_for_user(uid, project_id) is None:
|
||||
return not_found("Project")
|
||||
if not await can_write_project(uid, project_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
milestone = await get_milestone_in_project(project_id, milestone_id)
|
||||
if milestone is None:
|
||||
return not_found("Milestone")
|
||||
data = await request.get_json()
|
||||
allowed = {"title", "description", "status", "order_index"}
|
||||
fields = {k: v for k, v in data.items() if k in allowed}
|
||||
if "status" in fields and fields["status"] not in ("active", "done"):
|
||||
return jsonify({"error": "status must be 'active' or 'done'"}), 400
|
||||
updated = await update_milestone(milestone.user_id, milestone_id, **fields)
|
||||
if updated is None:
|
||||
return not_found("Milestone")
|
||||
return jsonify(await _milestone_dict(updated))
|
||||
|
||||
|
||||
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def delete_milestone_route(project_id: int, milestone_id: int):
|
||||
uid = get_current_user_id()
|
||||
if await get_project_for_user(uid, project_id) is None:
|
||||
return not_found("Project")
|
||||
if not await can_write_project(uid, project_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
milestone = await get_milestone_in_project(project_id, milestone_id)
|
||||
if milestone is None:
|
||||
return not_found("Milestone")
|
||||
from scribe.services.trash import delete as trash_delete
|
||||
batch = await trash_delete(milestone.user_id, "milestone", milestone_id)
|
||||
if batch is None:
|
||||
return not_found("Milestone")
|
||||
return "", 204
|
||||
|
||||
|
||||
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>/tasks", methods=["GET"])
|
||||
@login_required
|
||||
async def get_milestone_tasks_route(project_id: int, milestone_id: int):
|
||||
uid = get_current_user_id()
|
||||
if await get_project_for_user(uid, project_id) is None:
|
||||
return not_found("Project")
|
||||
milestone = await get_milestone_in_project(project_id, milestone_id)
|
||||
if milestone is None:
|
||||
return not_found("Milestone")
|
||||
status_filter = request.args.get("status")
|
||||
limit, offset = parse_pagination(default_limit=100)
|
||||
notes, total = await list_notes(
|
||||
milestone.user_id,
|
||||
is_task=True,
|
||||
status=status_filter,
|
||||
milestone_id=milestone_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
|
||||
@@ -0,0 +1,481 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
from scribe.services.embeddings import upsert_note_embedding
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import login_required, get_current_user_id
|
||||
from scribe.routes.utils import not_found, parse_iso_date, parse_pagination
|
||||
from scribe.services.access import can_write_note
|
||||
from scribe.services.notes import (
|
||||
build_note_graph,
|
||||
convert_note_to_task,
|
||||
convert_task_to_note,
|
||||
create_note,
|
||||
get_all_tags,
|
||||
get_backlinks,
|
||||
get_note,
|
||||
get_note_by_title,
|
||||
get_note_for_user,
|
||||
get_or_create_note_by_title,
|
||||
list_notes,
|
||||
update_note,
|
||||
)
|
||||
from scribe.services.note_drafts import upsert_draft, get_draft, delete_draft
|
||||
from scribe.services.note_versions import list_versions, get_version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
notes_bp = Blueprint("notes", __name__, url_prefix="/api/notes")
|
||||
|
||||
|
||||
@notes_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def list_notes_route():
|
||||
uid = get_current_user_id()
|
||||
q = request.args.get("q")
|
||||
tag = request.args.getlist("tag")
|
||||
sort = request.args.get("sort", "updated_at")
|
||||
order = request.args.get("order", "desc")
|
||||
limit, offset = parse_pagination()
|
||||
|
||||
# Default to non-task notes only; ?is_task=true for tasks, ?all=true for everything
|
||||
is_task: bool | None = False
|
||||
if request.args.get("all", "").lower() == "true":
|
||||
is_task = None
|
||||
elif request.args.get("is_task", "").lower() == "true":
|
||||
is_task = True
|
||||
|
||||
project_id = request.args.get("project_id", type=int)
|
||||
milestone_id = request.args.get("milestone_id", type=int)
|
||||
parent_id = request.args.get("parent_id", type=int)
|
||||
no_project = request.args.get("no_project", "").lower() == "true"
|
||||
|
||||
# type= shorthand used by web frontend (?type=task or ?type=note)
|
||||
type_param = request.args.get("type")
|
||||
if type_param == "task":
|
||||
is_task = True
|
||||
elif type_param == "note":
|
||||
is_task = False
|
||||
|
||||
status = request.args.getlist("status") or None
|
||||
priority = request.args.getlist("priority") or None
|
||||
|
||||
notes, total = await list_notes(
|
||||
uid, q=q, tags=tag or None, is_task=is_task, sort=sort, order=order,
|
||||
limit=limit, offset=offset,
|
||||
project_id=project_id, milestone_id=milestone_id, parent_id=parent_id,
|
||||
no_project=no_project, status=status, priority=priority,
|
||||
)
|
||||
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
|
||||
|
||||
|
||||
@notes_bp.route("", methods=["POST"])
|
||||
@login_required
|
||||
async def create_note_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
body = data.get("body", "")
|
||||
tags = data.get("tags", [])
|
||||
|
||||
# Optional task fields
|
||||
status = data.get("status")
|
||||
priority = data.get("priority")
|
||||
due_date = parse_iso_date(data.get("due_date"), "due_date")
|
||||
if isinstance(due_date, tuple):
|
||||
return due_date
|
||||
|
||||
project_id = data.get("project_id")
|
||||
if project_id is None and data.get("project"):
|
||||
from scribe.services.projects import get_project_by_title as _gpbt
|
||||
proj = await _gpbt(uid, data["project"])
|
||||
if proj:
|
||||
project_id = proj.id
|
||||
|
||||
note_type = data.get("note_type", "note")
|
||||
entity_meta = data.get("metadata") or None
|
||||
|
||||
try:
|
||||
note = await create_note(
|
||||
uid,
|
||||
title=data.get("title", ""),
|
||||
body=body,
|
||||
description=data.get("description"),
|
||||
tags=tags,
|
||||
parent_id=data.get("parent_id"),
|
||||
project_id=project_id,
|
||||
milestone_id=data.get("milestone_id"),
|
||||
status=status,
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
note_type=note_type,
|
||||
entity_meta=entity_meta,
|
||||
)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
|
||||
return jsonify(note.to_dict()), 201
|
||||
|
||||
|
||||
@notes_bp.route("/tags", methods=["GET"])
|
||||
@login_required
|
||||
async def list_tags_route():
|
||||
uid = get_current_user_id()
|
||||
q = request.args.get("q")
|
||||
tags = await get_all_tags(uid, q=q)
|
||||
return jsonify({"tags": tags})
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>/append-tag", methods=["POST"])
|
||||
@login_required
|
||||
async def append_tag_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
tag = data.get("tag", "").strip().replace(" ", "-")
|
||||
if not tag:
|
||||
return jsonify({"error": "tag is required"}), 400
|
||||
|
||||
note = await get_note(uid, note_id)
|
||||
if note is None:
|
||||
return not_found("Note")
|
||||
|
||||
existing = list(note.tags or [])
|
||||
if tag not in existing:
|
||||
existing.append(tag)
|
||||
updated = await update_note(uid, note_id, tags=existing)
|
||||
return jsonify(updated.to_dict())
|
||||
|
||||
|
||||
@notes_bp.route("/by-title", methods=["GET"])
|
||||
@login_required
|
||||
async def get_note_by_title_route():
|
||||
uid = get_current_user_id()
|
||||
title = request.args.get("title", "")
|
||||
if not title:
|
||||
return jsonify({"error": "title parameter is required"}), 400
|
||||
note = await get_note_by_title(uid, title)
|
||||
if note is None:
|
||||
return not_found("Note")
|
||||
return jsonify(note.to_dict())
|
||||
|
||||
|
||||
@notes_bp.route("/resolve-title", methods=["POST"])
|
||||
@login_required
|
||||
async def resolve_title_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
title = data.get("title", "").strip()
|
||||
if not title:
|
||||
return jsonify({"error": "title is required"}), 400
|
||||
note = await get_or_create_note_by_title(uid, title)
|
||||
return jsonify(note.to_dict())
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>", methods=["GET"])
|
||||
@login_required
|
||||
async def get_note_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
result = await get_note_for_user(uid, note_id)
|
||||
if result is None:
|
||||
return not_found("Note")
|
||||
note, permission = result
|
||||
data = note.to_dict()
|
||||
data["permission"] = permission
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>", methods=["PUT"])
|
||||
@login_required
|
||||
async def update_note_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
# Share-aware: resolve through the ACL and write as the OWNER, so a shared
|
||||
# editor's save isn't rejected by the owner-scoped update service.
|
||||
result = await get_note_for_user(uid, note_id)
|
||||
if result is None:
|
||||
return not_found("Note")
|
||||
note_obj, _ = result
|
||||
if not await can_write_note(uid, note_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
owner_uid = note_obj.user_id
|
||||
data = await request.get_json()
|
||||
fields = {}
|
||||
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
||||
if key in data:
|
||||
fields[key] = data[key]
|
||||
if "metadata" in data:
|
||||
fields["entity_meta"] = data["metadata"] or None
|
||||
|
||||
if "due_date" in data:
|
||||
if data["due_date"]:
|
||||
result = parse_iso_date(data["due_date"], "due_date")
|
||||
if isinstance(result, tuple):
|
||||
return result
|
||||
fields["due_date"] = result
|
||||
else:
|
||||
fields["due_date"] = None
|
||||
|
||||
if "tags" in data:
|
||||
fields["tags"] = data["tags"]
|
||||
try:
|
||||
note = await update_note(owner_uid, note_id, **fields)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
if note is None:
|
||||
return not_found("Note")
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text))
|
||||
return jsonify(note.to_dict())
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def patch_note_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
result = await get_note_for_user(uid, note_id)
|
||||
if result is None:
|
||||
return not_found("Note")
|
||||
note_obj, _ = result
|
||||
if not await can_write_note(uid, note_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
owner_uid = note_obj.user_id
|
||||
data = await request.get_json()
|
||||
fields = {}
|
||||
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
||||
if key in data:
|
||||
fields[key] = data[key]
|
||||
if "metadata" in data:
|
||||
fields["entity_meta"] = data["metadata"] or None
|
||||
if "due_date" in data:
|
||||
if data["due_date"]:
|
||||
result = parse_iso_date(data["due_date"], "due_date")
|
||||
if isinstance(result, tuple):
|
||||
return result
|
||||
fields["due_date"] = result
|
||||
else:
|
||||
fields["due_date"] = None
|
||||
if "tags" in data:
|
||||
fields["tags"] = data["tags"]
|
||||
try:
|
||||
note = await update_note(owner_uid, note_id, **fields)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
if note is None:
|
||||
return not_found("Note")
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text))
|
||||
return jsonify(note.to_dict())
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def delete_note_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
result = await get_note_for_user(uid, note_id)
|
||||
if result is None:
|
||||
return not_found("Note")
|
||||
note_obj, _ = result
|
||||
if not await can_write_note(uid, note_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
from scribe.services.trash import delete as trash_delete
|
||||
batch = await trash_delete(note_obj.user_id, "note", note_id)
|
||||
if batch is None:
|
||||
return not_found("Note")
|
||||
return "", 204
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>/convert-to-task", methods=["POST"])
|
||||
@login_required
|
||||
async def convert_note_to_task_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
try:
|
||||
note = await convert_note_to_task(uid, note_id)
|
||||
return jsonify(note.to_dict()), 200
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>/convert-to-note", methods=["POST"])
|
||||
@login_required
|
||||
async def convert_task_to_note_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
try:
|
||||
note = await convert_task_to_note(uid, note_id)
|
||||
return jsonify(note.to_dict()), 200
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>/backlinks", methods=["GET"])
|
||||
@login_required
|
||||
async def get_backlinks_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
links = await get_backlinks(uid, note_id)
|
||||
return jsonify({"backlinks": links})
|
||||
|
||||
|
||||
# ── Link suggestions ─────────────────────────────────────────────────────────
|
||||
|
||||
_WIKILINK_RE = re.compile(r'\[\[[^\]]+\]\]')
|
||||
|
||||
|
||||
def _find_unlinked_terms(body: str, note_titles: list[tuple[int, str]]) -> list[dict]:
|
||||
"""Return project note titles that appear in body as plain text (not inside [[...]])."""
|
||||
linked_ranges = [(m.start(), m.end()) for m in _WIKILINK_RE.finditer(body)]
|
||||
|
||||
def in_wikilink(start: int, end: int) -> bool:
|
||||
return any(ls <= start and end <= le for ls, le in linked_ranges)
|
||||
|
||||
suggestions = []
|
||||
for note_id, title in note_titles:
|
||||
title = title.strip()
|
||||
if len(title) < 3:
|
||||
continue
|
||||
pattern = re.compile(r'\b' + re.escape(title) + r'\b', re.IGNORECASE)
|
||||
count = sum(1 for m in pattern.finditer(body) if not in_wikilink(m.start(), m.end()))
|
||||
if count > 0:
|
||||
suggestions.append({"note_id": note_id, "title": title, "count": count})
|
||||
|
||||
suggestions.sort(key=lambda x: x["count"], reverse=True)
|
||||
return suggestions
|
||||
|
||||
|
||||
@notes_bp.route("/link-suggestions", methods=["POST"])
|
||||
@login_required
|
||||
async def link_suggestions_route():
|
||||
"""Find project note titles that appear unlinked in the given body text."""
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
|
||||
body_text = data.get("body", "")
|
||||
project_id = data.get("project_id")
|
||||
exclude_note_id = data.get("exclude_note_id")
|
||||
|
||||
if not project_id or not body_text:
|
||||
return jsonify({"suggestions": []})
|
||||
|
||||
try:
|
||||
project_notes, _ = await list_notes(
|
||||
uid, project_id=int(project_id), sort="title", order="asc", limit=200
|
||||
)
|
||||
titles = [
|
||||
(n.id, n.title)
|
||||
for n in project_notes
|
||||
if n.id != exclude_note_id and n.title
|
||||
]
|
||||
suggestions = _find_unlinked_terms(body_text, titles)
|
||||
except Exception:
|
||||
logger.warning("Failed to compute link suggestions", exc_info=True)
|
||||
suggestions = []
|
||||
|
||||
return jsonify({"suggestions": suggestions})
|
||||
|
||||
|
||||
# ── Draft routes ─────────────────────────────────────────────────────────────
|
||||
|
||||
@notes_bp.route("/<int:note_id>/draft", methods=["GET"])
|
||||
@login_required
|
||||
async def get_draft_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
draft = await get_draft(uid, note_id)
|
||||
if draft is None:
|
||||
return jsonify({"error": "No draft found"}), 404
|
||||
return jsonify(draft.to_dict())
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>/draft", methods=["PUT"])
|
||||
@login_required
|
||||
async def upsert_draft_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
# Verify note ownership
|
||||
note = await get_note(uid, note_id)
|
||||
if note is None:
|
||||
return not_found("Note")
|
||||
data = await request.get_json()
|
||||
draft = await upsert_draft(
|
||||
user_id=uid,
|
||||
note_id=note_id,
|
||||
proposed_body=data.get("proposed_body", ""),
|
||||
original_body=data.get("original_body", ""),
|
||||
instruction=data.get("instruction", ""),
|
||||
scope=data.get("scope", "document"),
|
||||
)
|
||||
return jsonify(draft.to_dict()), 200
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>/draft", methods=["DELETE"])
|
||||
@login_required
|
||||
async def delete_draft_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
await delete_draft(uid, note_id)
|
||||
return "", 204
|
||||
|
||||
|
||||
# ── Version routes ────────────────────────────────────────────────────────────
|
||||
|
||||
@notes_bp.route("/<int:note_id>/versions", methods=["GET"])
|
||||
@login_required
|
||||
async def list_versions_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
versions = await list_versions(uid, note_id)
|
||||
return jsonify({"versions": [v.to_dict(include_body=False) for v in versions]})
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>/versions/<int:version_id>", methods=["GET"])
|
||||
@login_required
|
||||
async def get_version_route(note_id: int, version_id: int):
|
||||
uid = get_current_user_id()
|
||||
version = await get_version(uid, note_id, version_id)
|
||||
if version is None:
|
||||
return not_found("Version")
|
||||
return jsonify(version.to_dict(include_body=True))
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>/versions/<int:version_id>/pin", methods=["POST"])
|
||||
@login_required
|
||||
async def pin_version_route(note_id: int, version_id: int):
|
||||
"""Mark a version as manually pinned. Body: {"label": str | null}."""
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json() or {}
|
||||
label = data.get("label")
|
||||
if label is not None and not isinstance(label, str):
|
||||
return jsonify({"error": "label must be a string or null"}), 400
|
||||
from scribe.services.version_pinning import pin_version
|
||||
try:
|
||||
version = await pin_version(uid, note_id, version_id, label=label)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
if version is None:
|
||||
return not_found("Version")
|
||||
return jsonify(version.to_dict(include_body=False))
|
||||
|
||||
|
||||
@notes_bp.route(
|
||||
"/<int:note_id>/versions/<int:version_id>/pin", methods=["DELETE"],
|
||||
)
|
||||
@login_required
|
||||
async def unpin_version_route(note_id: int, version_id: int):
|
||||
"""Downgrade a manually-pinned version back to rolling."""
|
||||
uid = get_current_user_id()
|
||||
from scribe.services.version_pinning import unpin_version
|
||||
version = await unpin_version(uid, note_id, version_id)
|
||||
if version is None:
|
||||
return not_found("Version")
|
||||
return jsonify(version.to_dict(include_body=False))
|
||||
|
||||
|
||||
# ── Graph route ────────────────────────────────────────────────────────────────
|
||||
|
||||
@notes_bp.route("/graph", methods=["GET"])
|
||||
@login_required
|
||||
async def graph_route():
|
||||
uid = get_current_user_id()
|
||||
project_id = request.args.get("project_id", type=int)
|
||||
shared_tags = request.args.get("shared_tags", "false").lower() == "true"
|
||||
graph = await build_note_graph(uid, project_id=project_id, include_shared_tags=shared_tags)
|
||||
return jsonify(graph)
|
||||
@@ -0,0 +1,43 @@
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.services.user_profile import (
|
||||
VALID_EXPERTISE,
|
||||
VALID_STYLES,
|
||||
VALID_TONES,
|
||||
get_profile,
|
||||
update_profile,
|
||||
)
|
||||
|
||||
profile_bp = Blueprint("profile", __name__, url_prefix="/api/profile")
|
||||
|
||||
|
||||
@profile_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def get_profile_route():
|
||||
uid = get_current_user_id()
|
||||
profile = await get_profile(uid)
|
||||
return jsonify(profile.to_dict())
|
||||
|
||||
|
||||
@profile_bp.route("", methods=["PUT"])
|
||||
@login_required
|
||||
async def update_profile_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "Expected a JSON object"}), 400
|
||||
|
||||
if "expertise_level" in data and data["expertise_level"] not in VALID_EXPERTISE:
|
||||
return jsonify({"error": f"expertise_level must be one of {sorted(VALID_EXPERTISE)}"}), 400
|
||||
if "response_style" in data and data["response_style"] not in VALID_STYLES:
|
||||
return jsonify({"error": f"response_style must be one of {sorted(VALID_STYLES)}"}), 400
|
||||
if "tone" in data and data["tone"] not in VALID_TONES:
|
||||
return jsonify({"error": f"tone must be one of {sorted(VALID_TONES)}"}), 400
|
||||
if "interests" in data and not isinstance(data["interests"], list):
|
||||
return jsonify({"error": "interests must be an array"}), 400
|
||||
if "work_schedule" in data and not isinstance(data["work_schedule"], dict):
|
||||
return jsonify({"error": "work_schedule must be an object"}), 400
|
||||
|
||||
profile = await update_profile(uid, data)
|
||||
return jsonify(profile.to_dict())
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Project management routes."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import login_required, get_current_user_id
|
||||
from scribe.routes.utils import not_found, parse_pagination
|
||||
from scribe.services.milestones import list_milestones
|
||||
from scribe.services.notes import list_notes
|
||||
from scribe.services.projects import (
|
||||
create_project,
|
||||
delete_project,
|
||||
get_project,
|
||||
get_project_for_user,
|
||||
get_project_summary,
|
||||
list_projects_for_user,
|
||||
update_project,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
projects_bp = Blueprint("projects", __name__, url_prefix="/api/projects")
|
||||
|
||||
|
||||
@projects_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def list_projects_route():
|
||||
uid = get_current_user_id()
|
||||
status = request.args.get("status")
|
||||
include_summary = request.args.get("include_summary", "").lower() in ("1", "true")
|
||||
projects = await list_projects_for_user(uid, status=status)
|
||||
if include_summary:
|
||||
# Fetch all summaries in parallel — one backend pass instead of N+1 frontend calls
|
||||
async def _attach(project_dict: dict) -> dict:
|
||||
try:
|
||||
owner_uid = project_dict.get("user_id") or uid # user_id now in to_dict()
|
||||
summary = await get_project_summary(owner_uid, project_dict["id"])
|
||||
project_dict["summary"] = summary
|
||||
except Exception:
|
||||
pass
|
||||
return project_dict
|
||||
projects = list(await asyncio.gather(*[_attach(p) for p in projects]))
|
||||
return jsonify({"projects": projects})
|
||||
|
||||
|
||||
@projects_bp.route("", methods=["POST"])
|
||||
@login_required
|
||||
async def create_project_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
if not data.get("title"):
|
||||
return jsonify({"error": "title is required"}), 400
|
||||
status = data.get("status", "active")
|
||||
if status not in ("active", "paused", "completed", "archived"):
|
||||
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
|
||||
project = await create_project(
|
||||
uid,
|
||||
title=data["title"],
|
||||
description=data.get("description", ""),
|
||||
goal=data.get("goal", ""),
|
||||
color=data.get("color"),
|
||||
status=status,
|
||||
)
|
||||
return jsonify(project.to_dict()), 201
|
||||
|
||||
|
||||
@projects_bp.route("/<int:project_id>", methods=["GET"])
|
||||
@login_required
|
||||
async def get_project_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
result = await get_project_for_user(uid, project_id)
|
||||
if result is None:
|
||||
return not_found("Project")
|
||||
project, permission = result
|
||||
# Summary uses the project owner's uid for stats when viewer is not the owner
|
||||
owner_uid = project.user_id or uid
|
||||
summary = await get_project_summary(owner_uid, project_id)
|
||||
data = project.to_dict()
|
||||
data["summary"] = summary
|
||||
data["permission"] = permission
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@projects_bp.route("/<int:project_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_project_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
allowed = {"title", "description", "goal", "status", "color"}
|
||||
fields = {k: (v if v is not None else "") for k, v in data.items() if k in allowed}
|
||||
if "status" in fields and fields["status"] not in ("active", "paused", "completed", "archived"):
|
||||
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
|
||||
project = await update_project(uid, project_id, **fields)
|
||||
if project is None:
|
||||
return not_found("Project")
|
||||
return jsonify(project.to_dict())
|
||||
|
||||
|
||||
@projects_bp.route("/<int:project_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def delete_project_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
from scribe.services.trash import delete as trash_delete
|
||||
batch = await trash_delete(uid, "project", project_id)
|
||||
if batch is None:
|
||||
return not_found("Project")
|
||||
return "", 204
|
||||
|
||||
|
||||
@projects_bp.route("/<int:project_id>/notes", methods=["GET"])
|
||||
@login_required
|
||||
async def get_project_notes_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
result = await get_project_for_user(uid, project_id)
|
||||
if result is None:
|
||||
return not_found("Project")
|
||||
project, _ = result
|
||||
# Use the project owner's uid so the ownership filter on notes/milestones
|
||||
# matches for shared collaborators (who'd otherwise see an empty panel).
|
||||
owner_uid = project.user_id or uid
|
||||
|
||||
# type filter: "note", "task", or None (both)
|
||||
type_filter = request.args.get("type")
|
||||
status_filter = request.args.get("status")
|
||||
limit, offset = parse_pagination(default_limit=100)
|
||||
|
||||
is_task: bool | None = None
|
||||
if type_filter == "task":
|
||||
is_task = True
|
||||
elif type_filter == "note":
|
||||
is_task = False
|
||||
|
||||
ms_list = await list_milestones(owner_uid, project_id)
|
||||
milestone_ids = [m.id for m in ms_list]
|
||||
|
||||
notes, total = await list_notes(
|
||||
owner_uid,
|
||||
is_task=is_task,
|
||||
status=status_filter,
|
||||
project_id=project_id,
|
||||
milestone_ids=milestone_ids,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
sort="updated_at",
|
||||
order="desc",
|
||||
)
|
||||
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Rulebook / topic REST endpoints.
|
||||
|
||||
Wraps services/rulebooks.py. Standard Scribe auth: g.user.id is the
|
||||
authenticated owner; the service enforces ownership scoping.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from scribe.auth import login_required
|
||||
import scribe.services.rulebooks as rulebooks_svc
|
||||
from scribe.services.trash import delete as trash_delete
|
||||
|
||||
rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
def _uid() -> int:
|
||||
return g.user.id
|
||||
|
||||
|
||||
# ── Rulebooks ───────────────────────────────────────────────────────────
|
||||
|
||||
@rulebooks_bp.get("/rulebooks")
|
||||
@login_required
|
||||
async def list_rulebooks():
|
||||
rows = await rulebooks_svc.list_rulebooks(_uid())
|
||||
return jsonify({"rulebooks": [rb.to_dict() for rb in rows]})
|
||||
|
||||
|
||||
@rulebooks_bp.post("/rulebooks")
|
||||
@login_required
|
||||
async def create_rulebook():
|
||||
data = await request.get_json() or {}
|
||||
title = (data.get("title") or "").strip()
|
||||
if not title:
|
||||
return jsonify({"error": "title is required"}), 400
|
||||
rb = await rulebooks_svc.create_rulebook(
|
||||
user_id=_uid(),
|
||||
title=title,
|
||||
description=data.get("description", ""),
|
||||
)
|
||||
return jsonify(rb.to_dict()), 201
|
||||
|
||||
|
||||
@rulebooks_bp.get("/rulebooks/<int:rulebook_id>")
|
||||
@login_required
|
||||
async def get_rulebook(rulebook_id: int):
|
||||
rb = await rulebooks_svc.get_rulebook(rulebook_id, _uid())
|
||||
if rb is None:
|
||||
return jsonify({"error": "rulebook not found"}), 404
|
||||
return jsonify(rb.to_dict())
|
||||
|
||||
|
||||
@rulebooks_bp.patch("/rulebooks/<int:rulebook_id>")
|
||||
@login_required
|
||||
async def update_rulebook(rulebook_id: int):
|
||||
data = await request.get_json() or {}
|
||||
fields = {k: v for k, v in data.items() if k in ("title", "description", "always_on")}
|
||||
rb = await rulebooks_svc.update_rulebook(rulebook_id, _uid(), **fields)
|
||||
if rb is None:
|
||||
return jsonify({"error": "rulebook not found"}), 404
|
||||
return jsonify(rb.to_dict())
|
||||
|
||||
|
||||
@rulebooks_bp.delete("/rulebooks/<int:rulebook_id>")
|
||||
@login_required
|
||||
async def delete_rulebook(rulebook_id: int):
|
||||
await trash_delete(_uid(), "rulebook", rulebook_id)
|
||||
return "", 204
|
||||
|
||||
|
||||
# ── Topics ──────────────────────────────────────────────────────────────
|
||||
|
||||
@rulebooks_bp.get("/rulebooks/<int:rulebook_id>/topics")
|
||||
@login_required
|
||||
async def list_topics(rulebook_id: int):
|
||||
try:
|
||||
rows = await rulebooks_svc.list_topics(rulebook_id, _uid())
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return jsonify({"topics": [t.to_dict() for t in rows]})
|
||||
|
||||
|
||||
@rulebooks_bp.post("/rulebooks/<int:rulebook_id>/topics")
|
||||
@login_required
|
||||
async def create_topic(rulebook_id: int):
|
||||
data = await request.get_json() or {}
|
||||
title = (data.get("title") or "").strip()
|
||||
if not title:
|
||||
return jsonify({"error": "title is required"}), 400
|
||||
try:
|
||||
topic = await rulebooks_svc.create_topic(
|
||||
rulebook_id=rulebook_id,
|
||||
user_id=_uid(),
|
||||
title=title,
|
||||
description=data.get("description", ""),
|
||||
order_index=data.get("order_index", 0),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return jsonify(topic.to_dict()), 201
|
||||
|
||||
|
||||
@rulebooks_bp.patch("/rulebook-topics/<int:topic_id>")
|
||||
@login_required
|
||||
async def update_topic(topic_id: int):
|
||||
data = await request.get_json() or {}
|
||||
fields = {
|
||||
k: v for k, v in data.items()
|
||||
if k in ("title", "description", "order_index")
|
||||
}
|
||||
topic = await rulebooks_svc.update_topic(topic_id, _uid(), **fields)
|
||||
if topic is None:
|
||||
return jsonify({"error": "topic not found"}), 404
|
||||
return jsonify(topic.to_dict())
|
||||
|
||||
|
||||
@rulebooks_bp.delete("/rulebook-topics/<int:topic_id>")
|
||||
@login_required
|
||||
async def delete_topic(topic_id: int):
|
||||
if await trash_delete(_uid(), "topic", topic_id) is None:
|
||||
return jsonify({"error": "topic not found"}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
# ── Rules ───────────────────────────────────────────────────────────────
|
||||
|
||||
@rulebooks_bp.get("/rules")
|
||||
@login_required
|
||||
async def list_rules():
|
||||
def _opt_int(name):
|
||||
raw = request.args.get(name)
|
||||
return int(raw) if raw else None
|
||||
|
||||
try:
|
||||
rulebook_id = _opt_int("rulebook_id")
|
||||
topic_id = _opt_int("topic_id")
|
||||
project_id = _opt_int("project_id")
|
||||
except ValueError:
|
||||
return jsonify({"error": "rulebook_id, topic_id, project_id must be integers"}), 400
|
||||
|
||||
rows = await rulebooks_svc.list_rules(
|
||||
user_id=_uid(),
|
||||
rulebook_id=rulebook_id,
|
||||
topic_id=topic_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
return jsonify({"rules": [r.to_dict() for r in rows]})
|
||||
|
||||
|
||||
@rulebooks_bp.post("/rulebook-topics/<int:topic_id>/rules")
|
||||
@login_required
|
||||
async def create_rule(topic_id: int):
|
||||
data = await request.get_json() or {}
|
||||
title = (data.get("title") or "").strip()
|
||||
statement = (data.get("statement") or "").strip()
|
||||
if not title or not statement:
|
||||
return jsonify({"error": "title and statement are required"}), 400
|
||||
try:
|
||||
rule = await rulebooks_svc.create_rule(
|
||||
topic_id=topic_id,
|
||||
user_id=_uid(),
|
||||
title=title,
|
||||
statement=statement,
|
||||
why=data.get("why", ""),
|
||||
how_to_apply=data.get("how_to_apply", ""),
|
||||
order_index=data.get("order_index", 0),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return jsonify(rule.to_dict()), 201
|
||||
|
||||
|
||||
@rulebooks_bp.get("/rules/<int:rule_id>")
|
||||
@login_required
|
||||
async def get_rule(rule_id: int):
|
||||
rule = await rulebooks_svc.get_rule(rule_id, _uid())
|
||||
if rule is None:
|
||||
return jsonify({"error": "rule not found"}), 404
|
||||
return jsonify(rule.to_dict())
|
||||
|
||||
|
||||
@rulebooks_bp.patch("/rules/<int:rule_id>")
|
||||
@login_required
|
||||
async def update_rule(rule_id: int):
|
||||
data = await request.get_json() or {}
|
||||
fields = {
|
||||
k: v for k, v in data.items()
|
||||
if k in ("title", "statement", "why", "how_to_apply", "order_index")
|
||||
}
|
||||
rule = await rulebooks_svc.update_rule(rule_id, _uid(), **fields)
|
||||
if rule is None:
|
||||
return jsonify({"error": "rule not found"}), 404
|
||||
return jsonify(rule.to_dict())
|
||||
|
||||
|
||||
@rulebooks_bp.delete("/rules/<int:rule_id>")
|
||||
@login_required
|
||||
async def delete_rule(rule_id: int):
|
||||
if await trash_delete(_uid(), "rule", rule_id) is None:
|
||||
return jsonify({"error": "rule not found"}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
# ── Subscriptions ──────────────────────────────────────────────────────
|
||||
|
||||
@rulebooks_bp.post("/projects/<int:project_id>/rulebook-subscriptions")
|
||||
@login_required
|
||||
async def subscribe_project(project_id: int):
|
||||
data = await request.get_json() or {}
|
||||
rulebook_id = data.get("rulebook_id")
|
||||
if not rulebook_id:
|
||||
return jsonify({"error": "rulebook_id is required"}), 400
|
||||
try:
|
||||
await rulebooks_svc.subscribe_project(
|
||||
project_id=project_id, rulebook_id=int(rulebook_id), user_id=_uid(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@rulebooks_bp.delete(
|
||||
"/projects/<int:project_id>/rulebook-subscriptions/<int:rulebook_id>"
|
||||
)
|
||||
@login_required
|
||||
async def unsubscribe_project(project_id: int, rulebook_id: int):
|
||||
try:
|
||||
await rulebooks_svc.unsubscribe_project(
|
||||
project_id=project_id, rulebook_id=rulebook_id, user_id=_uid(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@rulebooks_bp.get("/projects/<int:project_id>/rules")
|
||||
@login_required
|
||||
async def get_project_rules(project_id: int):
|
||||
result = await rulebooks_svc.get_applicable_rules(
|
||||
project_id=project_id, user_id=_uid(),
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
|
||||
@login_required
|
||||
async def suppress_project_rule(project_id: int, rule_id: int):
|
||||
try:
|
||||
await rulebooks_svc.suppress_rule_for_project(
|
||||
project_id=project_id, rule_id=rule_id, user_id=_uid(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
|
||||
@login_required
|
||||
async def unsuppress_project_rule(project_id: int, rule_id: int):
|
||||
try:
|
||||
await rulebooks_svc.unsuppress_rule_for_project(
|
||||
project_id=project_id, rule_id=rule_id, user_id=_uid(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
|
||||
@login_required
|
||||
async def suppress_project_topic(project_id: int, topic_id: int):
|
||||
try:
|
||||
await rulebooks_svc.suppress_topic_for_project(
|
||||
project_id=project_id, topic_id=topic_id, user_id=_uid(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
|
||||
@login_required
|
||||
async def unsuppress_project_topic(project_id: int, topic_id: int):
|
||||
try:
|
||||
await rulebooks_svc.unsuppress_topic_for_project(
|
||||
project_id=project_id, topic_id=topic_id, user_id=_uid(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@rulebooks_bp.post("/projects/<int:project_id>/rules")
|
||||
@login_required
|
||||
async def create_project_rule(project_id: int):
|
||||
"""Create a rule scoped to a single project. Frontend fast path."""
|
||||
data = await request.get_json() or {}
|
||||
statement = (data.get("statement") or "").strip()
|
||||
if not statement:
|
||||
return jsonify({"error": "statement is required"}), 400
|
||||
title = (data.get("title") or "").strip() or statement.split(".")[0][:50]
|
||||
try:
|
||||
rule = await rulebooks_svc.create_project_rule(
|
||||
project_id=project_id,
|
||||
user_id=_uid(),
|
||||
title=title,
|
||||
statement=statement,
|
||||
why=data.get("why", ""),
|
||||
how_to_apply=data.get("how_to_apply", ""),
|
||||
order_index=data.get("order_index", 0),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return jsonify(rule.to_dict()), 201
|
||||
@@ -0,0 +1,46 @@
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import login_required, get_current_user_id
|
||||
from scribe.services.embeddings import semantic_search_notes
|
||||
|
||||
search_bp = Blueprint("search", __name__, url_prefix="/api/search")
|
||||
|
||||
|
||||
def _content_type_to_is_task(content_type: str) -> bool | None:
|
||||
"""Map content_type query param to semantic_search_notes is_task arg."""
|
||||
if content_type == "note":
|
||||
return False
|
||||
if content_type == "task":
|
||||
return True
|
||||
return None # "all" or unknown → no filter
|
||||
|
||||
|
||||
@search_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def search_route():
|
||||
uid = get_current_user_id()
|
||||
q = (request.args.get("q") or "").strip()
|
||||
if not q:
|
||||
return jsonify({"error": "q is required"}), 400
|
||||
|
||||
content_type = request.args.get("content_type", "all")
|
||||
limit = min(request.args.get("limit", 10, type=int), 50)
|
||||
is_task = _content_type_to_is_task(content_type)
|
||||
|
||||
results = await semantic_search_notes(
|
||||
uid, q, limit=limit, is_task=is_task, threshold=0.3
|
||||
)
|
||||
return jsonify({
|
||||
"results": [
|
||||
{
|
||||
"id": note.id,
|
||||
"title": note.title,
|
||||
"body": note.body or "",
|
||||
"is_task": note.is_task,
|
||||
"tags": note.tags or [],
|
||||
"similarity": score,
|
||||
}
|
||||
for score, note in results # semantic_search_notes returns list[tuple[float, Note]]
|
||||
],
|
||||
"total": len(results),
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
"""User settings + integrations (CalDAV, SearXNG status).
|
||||
|
||||
Chat-model picker endpoints (/models), KV-cache priming, and journal-schedule
|
||||
hooks were removed in Phase 8 alongside the chat/journal subsystems.
|
||||
"""
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import login_required, get_current_user_id
|
||||
from scribe.config import Config
|
||||
from scribe.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection
|
||||
from scribe.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_private_url(url: str) -> bool:
|
||||
"""SSRF-blocking helper: returns True for URLs that resolve to private,
|
||||
loopback, or link-local addresses. Inlined here after services/llm.py
|
||||
(the original home) was removed in Phase 8."""
|
||||
try:
|
||||
host = urlparse(url).hostname
|
||||
if not host:
|
||||
return True
|
||||
# Resolve to all addresses; reject if any is private/loopback/link-local.
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
for family, *_rest, sockaddr in infos:
|
||||
ip_str = sockaddr[0]
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
except ValueError:
|
||||
continue
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||
return True
|
||||
except Exception:
|
||||
# Conservative: if we can't resolve, treat as private (reject).
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
|
||||
|
||||
|
||||
@settings_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def get_settings_route():
|
||||
uid = get_current_user_id()
|
||||
settings = await get_all_settings(uid)
|
||||
return jsonify(settings)
|
||||
|
||||
|
||||
@settings_bp.route("", methods=["PUT"])
|
||||
@login_required
|
||||
async def update_settings_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "Expected a JSON object"}), 400
|
||||
|
||||
to_save = {}
|
||||
for k, v in data.items():
|
||||
str_v = str(v)
|
||||
if not str_v:
|
||||
await delete_setting(uid, k)
|
||||
else:
|
||||
to_save[k] = str_v
|
||||
|
||||
if to_save:
|
||||
await set_settings_batch(uid, to_save)
|
||||
|
||||
settings = await get_all_settings(uid)
|
||||
return jsonify(settings)
|
||||
|
||||
|
||||
@settings_bp.route("/caldav", methods=["GET"])
|
||||
@login_required
|
||||
async def get_caldav():
|
||||
uid = get_current_user_id()
|
||||
config = await get_caldav_config(uid)
|
||||
if config.get("caldav_password"):
|
||||
config["caldav_password"] = "********"
|
||||
return jsonify(config)
|
||||
|
||||
|
||||
@settings_bp.route("/caldav", methods=["PUT"])
|
||||
@login_required
|
||||
async def update_caldav():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
|
||||
# Validate CalDAV URL before saving — block internal/private addresses
|
||||
if "caldav_url" in data:
|
||||
url = str(data.get("caldav_url") or "").strip()
|
||||
if url:
|
||||
parsed_scheme = url.split("://")[0].lower() if "://" in url else ""
|
||||
if parsed_scheme not in ("http", "https"):
|
||||
return jsonify({"error": "CalDAV URL must use http or https"}), 400
|
||||
if _is_private_url(url):
|
||||
return jsonify({"error": "CalDAV URL must not point to an internal or private address"}), 400
|
||||
|
||||
settings_to_save = {}
|
||||
for key in CALDAV_SETTING_KEYS:
|
||||
if key in data:
|
||||
if key == "caldav_password" and data[key] == "********":
|
||||
continue
|
||||
settings_to_save[key] = str(data[key])
|
||||
|
||||
if settings_to_save:
|
||||
await set_settings_batch(uid, settings_to_save)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@settings_bp.route("/caldav/test", methods=["POST"])
|
||||
@login_required
|
||||
async def test_caldav():
|
||||
uid = get_current_user_id()
|
||||
result = await test_connection(uid)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@settings_bp.route("/search", methods=["GET"])
|
||||
@login_required
|
||||
async def test_search():
|
||||
"""Report SearXNG configuration status (used by the Integrations tab)."""
|
||||
if not Config.searxng_enabled():
|
||||
return jsonify({"configured": False, "results": [], "searxng_url": ""})
|
||||
return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL})
|
||||
@@ -0,0 +1,128 @@
|
||||
import asyncio
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.services import sharing as share_svc
|
||||
from scribe.services.access import can_admin_project, can_write_note
|
||||
from scribe.services.notifications import notify_note_shared, notify_project_shared
|
||||
from scribe.services.sharing import list_shared_with_me
|
||||
|
||||
shares_bp = Blueprint("shares", __name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project shares
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@shares_bp.route("/api/projects/<int:project_id>/shares", methods=["GET"])
|
||||
@login_required
|
||||
async def list_project_shares(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
if not await can_admin_project(uid, project_id):
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
return jsonify({"shares": await share_svc.list_project_shares(project_id)})
|
||||
|
||||
|
||||
@shares_bp.route("/api/projects/<int:project_id>/shares", methods=["POST"])
|
||||
@login_required
|
||||
async def create_project_share(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
share = await share_svc.share_project(
|
||||
uid, project_id,
|
||||
target_user_id=data.get("user_id"),
|
||||
target_group_id=data.get("group_id"),
|
||||
permission=data.get("permission", "viewer"),
|
||||
)
|
||||
if not share:
|
||||
return jsonify({"error": "Forbidden or invalid permission"}), 403
|
||||
asyncio.create_task(notify_project_shared(
|
||||
project_id, share.permission, uid,
|
||||
data.get("user_id"), data.get("group_id"),
|
||||
))
|
||||
return jsonify(share.to_dict()), 201
|
||||
|
||||
|
||||
@shares_bp.route("/api/projects/<int:project_id>/shares/<int:share_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_project_share(project_id: int, share_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
share = await share_svc.update_project_share(uid, share_id, data.get("permission", ""))
|
||||
if not share:
|
||||
return jsonify({"error": "Not found or forbidden"}), 404
|
||||
return jsonify(share.to_dict())
|
||||
|
||||
|
||||
@shares_bp.route("/api/projects/<int:project_id>/shares/<int:share_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def remove_project_share(project_id: int, share_id: int):
|
||||
uid = get_current_user_id()
|
||||
if not await share_svc.remove_project_share(uid, share_id):
|
||||
return jsonify({"error": "Not found or forbidden"}), 404
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Note / task shares
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@shares_bp.route("/api/notes/<int:note_id>/shares", methods=["GET"])
|
||||
@login_required
|
||||
async def list_note_shares(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
if not await can_write_note(uid, note_id):
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
return jsonify({"shares": await share_svc.list_note_shares(note_id)})
|
||||
|
||||
|
||||
@shares_bp.route("/api/notes/<int:note_id>/shares", methods=["POST"])
|
||||
@login_required
|
||||
async def create_note_share(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
share = await share_svc.share_note(
|
||||
uid, note_id,
|
||||
target_user_id=data.get("user_id"),
|
||||
target_group_id=data.get("group_id"),
|
||||
permission=data.get("permission", "viewer"),
|
||||
)
|
||||
if not share:
|
||||
return jsonify({"error": "Forbidden or invalid permission"}), 403
|
||||
asyncio.create_task(notify_note_shared(
|
||||
note_id, share.permission, uid,
|
||||
data.get("user_id"), data.get("group_id"),
|
||||
))
|
||||
return jsonify(share.to_dict()), 201
|
||||
|
||||
|
||||
@shares_bp.route("/api/notes/<int:note_id>/shares/<int:share_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_note_share(note_id: int, share_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
share = await share_svc.update_note_share(uid, share_id, data.get("permission", ""))
|
||||
if not share:
|
||||
return jsonify({"error": "Not found or forbidden"}), 404
|
||||
return jsonify(share.to_dict())
|
||||
|
||||
|
||||
@shares_bp.route("/api/notes/<int:note_id>/shares/<int:share_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def remove_note_share(note_id: int, share_id: int):
|
||||
uid = get_current_user_id()
|
||||
if not await share_svc.remove_note_share(uid, share_id):
|
||||
return jsonify({"error": "Not found or forbidden"}), 404
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared-with-me
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@shares_bp.route("/api/shared-with-me", methods=["GET"])
|
||||
@login_required
|
||||
async def shared_with_me():
|
||||
uid = get_current_user_id()
|
||||
return jsonify(await list_shared_with_me(uid))
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Task work log routes — /api/tasks/<task_id>/logs."""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.services.task_logs import create_log, delete_log, list_logs, update_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
task_logs_bp = Blueprint("task_logs", __name__, url_prefix="/api/tasks")
|
||||
|
||||
|
||||
@task_logs_bp.route("/<int:task_id>/logs", methods=["GET"])
|
||||
@login_required
|
||||
async def list_logs_route(task_id: int):
|
||||
uid = get_current_user_id()
|
||||
logs = await list_logs(uid, task_id)
|
||||
return jsonify({"logs": [log.to_dict() for log in logs]})
|
||||
|
||||
|
||||
@task_logs_bp.route("/<int:task_id>/logs", methods=["POST"])
|
||||
@login_required
|
||||
async def create_log_route(task_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
content = (data or {}).get("content", "").strip()
|
||||
if not content:
|
||||
return jsonify({"error": "content is required"}), 400
|
||||
duration_minutes = (data or {}).get("duration_minutes")
|
||||
if duration_minutes is not None:
|
||||
try:
|
||||
duration_minutes = int(duration_minutes)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "duration_minutes must be an integer"}), 400
|
||||
try:
|
||||
log = await create_log(uid, task_id, content, duration_minutes)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
return jsonify(log.to_dict()), 201
|
||||
|
||||
|
||||
@task_logs_bp.route("/<int:task_id>/logs/<int:log_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_log_route(task_id: int, log_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json() or {}
|
||||
content = data.get("content")
|
||||
if content is not None:
|
||||
content = content.strip() or None
|
||||
|
||||
from scribe.services.task_logs import _UNSET
|
||||
duration_minutes = data.get("duration_minutes", _UNSET)
|
||||
|
||||
log = await update_log(uid, log_id, content=content, duration_minutes=duration_minutes)
|
||||
if log is None:
|
||||
return jsonify({"error": "Log entry not found"}), 404
|
||||
return jsonify(log.to_dict())
|
||||
|
||||
|
||||
@task_logs_bp.route("/<int:task_id>/logs/<int:log_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def delete_log_route(task_id: int, log_id: int):
|
||||
uid = get_current_user_id()
|
||||
deleted = await delete_log(uid, log_id)
|
||||
if not deleted:
|
||||
return jsonify({"error": "Log entry not found"}), 404
|
||||
return "", 204
|
||||
@@ -0,0 +1,308 @@
|
||||
import asyncio
|
||||
from datetime import date
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import login_required, get_current_user_id
|
||||
from scribe.models.note import TaskPriority, TaskStatus
|
||||
from scribe.routes.utils import not_found, parse_iso_date, parse_pagination
|
||||
from scribe.services.access import can_write_note
|
||||
from scribe.services.embeddings import upsert_note_embedding
|
||||
from scribe.services.notes import (
|
||||
create_note,
|
||||
get_note,
|
||||
get_note_for_user,
|
||||
list_notes,
|
||||
update_note,
|
||||
)
|
||||
from scribe.services.planning import start_planning as svc_start_planning
|
||||
from scribe.services.recurrence import calculate_next_due, validate_recurrence_rule
|
||||
|
||||
tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _parse_recurrence_rule(data: dict) -> tuple[object, tuple | None]:
|
||||
"""Extract and validate recurrence_rule from request data.
|
||||
|
||||
Returns (rule_or_UNSET, error_response_or_None):
|
||||
_UNSET → key not present, do nothing
|
||||
None → key present and null, clear the rule
|
||||
dict → key present and valid, set the rule
|
||||
"""
|
||||
if "recurrence_rule" not in data:
|
||||
return _UNSET, None
|
||||
rule = data["recurrence_rule"]
|
||||
if rule is None:
|
||||
return None, None
|
||||
try:
|
||||
validate_recurrence_rule(rule)
|
||||
except ValueError as exc:
|
||||
return _UNSET, (jsonify({"error": str(exc)}), 400)
|
||||
return rule, None
|
||||
|
||||
|
||||
@tasks_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def list_tasks_route():
|
||||
uid = get_current_user_id()
|
||||
q = request.args.get("q")
|
||||
tag = request.args.getlist("tag")
|
||||
status = request.args.getlist("status") or None
|
||||
priority = request.args.getlist("priority") or None
|
||||
sort = request.args.get("sort", "updated_at")
|
||||
order = request.args.get("order", "desc")
|
||||
limit, offset = parse_pagination()
|
||||
|
||||
project_id = request.args.get("project_id", type=int)
|
||||
no_project = request.args.get("no_project", "").lower() == "true"
|
||||
kind = request.args.get("kind") or None
|
||||
|
||||
due_before = parse_iso_date(request.args.get("due_before"), "due_before")
|
||||
if isinstance(due_before, tuple):
|
||||
return due_before
|
||||
due_after = parse_iso_date(request.args.get("due_after"), "due_after")
|
||||
if isinstance(due_after, tuple):
|
||||
return due_after
|
||||
|
||||
tasks, total = await list_notes(
|
||||
uid,
|
||||
q=q,
|
||||
tags=tag or None,
|
||||
is_task=True,
|
||||
status=status,
|
||||
priority=priority,
|
||||
due_before=due_before,
|
||||
due_after=due_after,
|
||||
project_id=project_id,
|
||||
task_kind=kind,
|
||||
no_project=no_project,
|
||||
sort=sort,
|
||||
order=order,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return jsonify({"tasks": [t.to_dict() for t in tasks], "total": total})
|
||||
|
||||
|
||||
@tasks_bp.route("", methods=["POST"])
|
||||
@login_required
|
||||
async def create_task_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
# Description (user-stated goal) and body (machine-maintained summary) are
|
||||
# separate fields under the task-as-durable-record design. Don't fold one
|
||||
# into the other.
|
||||
body = data.get("body", "")
|
||||
description = data.get("description")
|
||||
tags = data.get("tags", [])
|
||||
|
||||
due_date = parse_iso_date(data.get("due_date"), "due_date")
|
||||
if isinstance(due_date, tuple):
|
||||
return due_date
|
||||
|
||||
try:
|
||||
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid status: {data['status']}"}), 400
|
||||
try:
|
||||
priority = TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400
|
||||
|
||||
recurrence_rule, recurrence_err = _parse_recurrence_rule(data)
|
||||
if recurrence_err:
|
||||
return recurrence_err
|
||||
|
||||
project_id = data.get("project_id")
|
||||
if project_id is None and data.get("project"):
|
||||
from scribe.services.projects import get_project_by_title as _gpbt
|
||||
proj = await _gpbt(uid, data["project"])
|
||||
if proj:
|
||||
project_id = proj.id
|
||||
|
||||
task = await create_note(
|
||||
uid,
|
||||
title=data.get("title", ""),
|
||||
body=body,
|
||||
description=description,
|
||||
status=status,
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
tags=tags,
|
||||
project_id=project_id,
|
||||
milestone_id=data.get("milestone_id"),
|
||||
parent_id=data.get("parent_id"),
|
||||
recurrence_rule=recurrence_rule if recurrence_rule is not _UNSET else None,
|
||||
task_kind=data.get("kind", "work"),
|
||||
)
|
||||
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
|
||||
return jsonify(task.to_dict()), 201
|
||||
|
||||
|
||||
@tasks_bp.route("/planning", methods=["POST"])
|
||||
@login_required
|
||||
async def start_planning_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json() or {}
|
||||
project_id = data.get("project_id")
|
||||
title = (data.get("title") or "").strip()
|
||||
if not project_id or not title:
|
||||
return jsonify({"error": "project_id and title are required"}), 400
|
||||
try:
|
||||
result = await svc_start_planning(
|
||||
user_id=uid, project_id=int(project_id), title=title,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return jsonify(result), 201
|
||||
|
||||
|
||||
@tasks_bp.route("/<int:task_id>", methods=["GET"])
|
||||
@login_required
|
||||
async def get_task_route(task_id: int):
|
||||
uid = get_current_user_id()
|
||||
result = await get_note_for_user(uid, task_id)
|
||||
if result is None:
|
||||
return not_found("Task")
|
||||
task, permission = result
|
||||
data = task.to_dict()
|
||||
data["permission"] = permission
|
||||
if task.parent_id:
|
||||
parent = await get_note(uid, task.parent_id)
|
||||
data["parent_title"] = parent.title if parent else None
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@tasks_bp.route("/<int:task_id>", methods=["PUT", "PATCH"])
|
||||
@login_required
|
||||
async def update_task_route(task_id: int):
|
||||
uid = get_current_user_id()
|
||||
result = await get_note_for_user(uid, task_id)
|
||||
if result is None:
|
||||
return not_found("Task")
|
||||
task_note, _ = result
|
||||
if not await can_write_note(uid, task_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
|
||||
data = await request.get_json()
|
||||
fields = {}
|
||||
for key in ("title",):
|
||||
if key in data:
|
||||
fields[key] = data[key]
|
||||
if "status" in data:
|
||||
try:
|
||||
fields["status"] = TaskStatus(data["status"]).value
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid status: {data['status']}"}), 400
|
||||
if "priority" in data:
|
||||
try:
|
||||
fields["priority"] = TaskPriority(data["priority"]).value
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400
|
||||
|
||||
# Body and description are distinct fields under the task-as-durable-record
|
||||
# design. Don't alias one to the other.
|
||||
if "body" in data:
|
||||
fields["body"] = data["body"]
|
||||
if "description" in data:
|
||||
fields["description"] = data["description"]
|
||||
|
||||
if "due_date" in data:
|
||||
if data["due_date"]:
|
||||
result = parse_iso_date(data["due_date"], "due_date")
|
||||
if isinstance(result, tuple):
|
||||
return result
|
||||
fields["due_date"] = result
|
||||
else:
|
||||
fields["due_date"] = None
|
||||
|
||||
if "tags" in data:
|
||||
fields["tags"] = data["tags"]
|
||||
|
||||
for key in ("project_id", "milestone_id", "parent_id"):
|
||||
if key in data:
|
||||
fields[key] = data[key]
|
||||
|
||||
recurrence_rule, recurrence_err = _parse_recurrence_rule(data)
|
||||
if recurrence_err:
|
||||
return recurrence_err
|
||||
if recurrence_rule is not _UNSET:
|
||||
fields["recurrence_rule"] = recurrence_rule
|
||||
|
||||
task = await update_note(task_note.user_id, task_id, **fields)
|
||||
if task is None:
|
||||
return not_found("Task")
|
||||
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(task.id, task_note.user_id, text))
|
||||
return jsonify(task.to_dict())
|
||||
|
||||
|
||||
@tasks_bp.route("/<int:task_id>/status", methods=["PATCH"])
|
||||
@login_required
|
||||
async def patch_task_status(task_id: int):
|
||||
uid = get_current_user_id()
|
||||
result = await get_note_for_user(uid, task_id)
|
||||
if result is None:
|
||||
return not_found("Task")
|
||||
task_note, _ = result
|
||||
if not await can_write_note(uid, task_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
|
||||
data = await request.get_json()
|
||||
status_val = data.get("status")
|
||||
if not status_val:
|
||||
return jsonify({"error": "status is required"}), 400
|
||||
try:
|
||||
TaskStatus(status_val)
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid status: {status_val}"}), 400
|
||||
|
||||
task = await update_note(task_note.user_id, task_id, status=status_val)
|
||||
if task is None:
|
||||
return not_found("Task")
|
||||
return jsonify(task.to_dict())
|
||||
|
||||
|
||||
@tasks_bp.route("/<int:task_id>/recurrence-preview", methods=["GET"])
|
||||
@login_required
|
||||
async def recurrence_preview_route(task_id: int):
|
||||
uid = get_current_user_id()
|
||||
result = await get_note_for_user(uid, task_id)
|
||||
if result is None:
|
||||
return not_found("Task")
|
||||
task, _ = result
|
||||
if not task.recurrence_rule:
|
||||
return jsonify({"error": "Task has no recurrence rule"}), 400
|
||||
|
||||
count = min(request.args.get("count", 5, type=int), 10)
|
||||
base = task.due_date or date.today()
|
||||
dates = []
|
||||
current = base
|
||||
for _ in range(count):
|
||||
current = calculate_next_due(task.recurrence_rule, current)
|
||||
dates.append(current.isoformat())
|
||||
return jsonify({"dates": dates})
|
||||
|
||||
|
||||
@tasks_bp.route("/<int:task_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def delete_task_route(task_id: int):
|
||||
uid = get_current_user_id()
|
||||
result = await get_note_for_user(uid, task_id)
|
||||
if result is None:
|
||||
return not_found("Task")
|
||||
task_note, _ = result
|
||||
if not await can_write_note(uid, task_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
from scribe.services.trash import delete as trash_delete
|
||||
batch = await trash_delete(task_note.user_id, "task", task_id)
|
||||
if batch is None:
|
||||
return not_found("Task")
|
||||
return "", 204
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Trash REST API — list / restore / purge soft-deleted content by batch."""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify
|
||||
|
||||
from scribe.auth import login_required
|
||||
import scribe.services.trash as trash_svc
|
||||
|
||||
trash_bp = Blueprint("trash", __name__, url_prefix="/api/trash")
|
||||
|
||||
|
||||
def _uid() -> int:
|
||||
return g.user.id
|
||||
|
||||
|
||||
@trash_bp.get("")
|
||||
@login_required
|
||||
async def list_trash():
|
||||
return jsonify({"batches": await trash_svc.list_trash(_uid())})
|
||||
|
||||
|
||||
@trash_bp.post("/<batch_id>/restore")
|
||||
@login_required
|
||||
async def restore_batch(batch_id: str):
|
||||
n = await trash_svc.restore(_uid(), batch_id)
|
||||
return jsonify({"restored": n})
|
||||
|
||||
|
||||
@trash_bp.delete("/<batch_id>")
|
||||
@login_required
|
||||
async def purge_batch(batch_id: str):
|
||||
n = await trash_svc.purge(_uid(), batch_id)
|
||||
return jsonify({"purged": n})
|
||||
@@ -0,0 +1,29 @@
|
||||
from quart import Blueprint, jsonify, request
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.models import async_session
|
||||
from scribe.models.user import User
|
||||
|
||||
users_bp = Blueprint("users", __name__, url_prefix="/api/users")
|
||||
|
||||
|
||||
@users_bp.route("/search", methods=["GET"])
|
||||
@login_required
|
||||
async def search_users():
|
||||
uid = get_current_user_id()
|
||||
q = (request.args.get("q") or "").strip()
|
||||
if len(q) < 2:
|
||||
return jsonify({"users": []})
|
||||
like = f"{q}%"
|
||||
async with async_session() as session:
|
||||
users = (await session.execute(
|
||||
select(User).where(
|
||||
User.id != uid,
|
||||
or_(User.username.ilike(like), User.email.ilike(like)),
|
||||
).limit(10)
|
||||
)).scalars().all()
|
||||
return jsonify({"users": [
|
||||
{"id": u.id, "username": u.username}
|
||||
for u in users
|
||||
]})
|
||||
@@ -0,0 +1,24 @@
|
||||
from datetime import date
|
||||
|
||||
from quart import jsonify, request
|
||||
|
||||
|
||||
def not_found(resource: str = "Item"):
|
||||
return jsonify({"error": f"{resource} not found"}), 404
|
||||
|
||||
|
||||
def parse_iso_date(value: str | None, field: str = "date"):
|
||||
"""Parse an ISO date string. Returns a date, None, or a (response, 400) tuple."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid {field} format. Use YYYY-MM-DD."}), 400
|
||||
|
||||
|
||||
def parse_pagination(default_limit: int = 50, max_limit: int = 500) -> tuple[int, int]:
|
||||
"""Extract and clamp ``limit`` / ``offset`` from the current request's query string."""
|
||||
limit = min(request.args.get("limit", default_limit, type=int), max_limit)
|
||||
offset = request.args.get("offset", 0, type=int)
|
||||
return limit, offset
|
||||
Reference in New Issue
Block a user