Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0)
This commit is contained in:
0
src/api/__init__.py
Normal file
0
src/api/__init__.py
Normal file
1157
src/api/admin.py
Normal file
1157
src/api/admin.py
Normal file
File diff suppressed because it is too large
Load Diff
2259
src/api/api_v1.py
Normal file
2259
src/api/api_v1.py
Normal file
File diff suppressed because it is too large
Load Diff
107
src/api/audit.py
Normal file
107
src/api/audit.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Admin API endpoints for audit log queries.
|
||||
|
||||
Loi 25 compliance: allows administrators to review access and auth logs.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from src.services.audit import get_access_logs, get_auth_logs, is_audit_enabled
|
||||
from src.utils import admin_required
|
||||
|
||||
audit_bp = Blueprint('audit', __name__)
|
||||
|
||||
|
||||
@audit_bp.route('/api/admin/audit/status', methods=['GET'])
|
||||
@admin_required
|
||||
def audit_status():
|
||||
"""Check if audit logging is enabled."""
|
||||
return jsonify({'enabled': is_audit_enabled()})
|
||||
|
||||
|
||||
@audit_bp.route('/api/admin/audit/access', methods=['GET'])
|
||||
@admin_required
|
||||
def list_access_logs():
|
||||
"""List access logs with pagination and filters."""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = min(request.args.get('per_page', 50, type=int), 200)
|
||||
user_id = request.args.get('user_id', type=int)
|
||||
resource_type = request.args.get('resource_type')
|
||||
resource_id = request.args.get('resource_id', type=int)
|
||||
action = request.args.get('action')
|
||||
|
||||
result = get_access_logs(
|
||||
page=page, per_page=per_page,
|
||||
user_id=user_id, resource_type=resource_type,
|
||||
resource_id=resource_id, action=action,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'logs': [log.to_dict() for log in result.items],
|
||||
'total': result.total,
|
||||
'page': result.page,
|
||||
'per_page': per_page,
|
||||
'pages': result.pages,
|
||||
})
|
||||
|
||||
|
||||
@audit_bp.route('/api/admin/audit/auth', methods=['GET'])
|
||||
@admin_required
|
||||
def list_auth_logs():
|
||||
"""List authentication logs with pagination and filters."""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = min(request.args.get('per_page', 50, type=int), 200)
|
||||
user_id = request.args.get('user_id', type=int)
|
||||
action = request.args.get('action')
|
||||
|
||||
result = get_auth_logs(page=page, per_page=per_page, user_id=user_id, action=action)
|
||||
|
||||
return jsonify({
|
||||
'logs': [log.to_dict() for log in result.items],
|
||||
'total': result.total,
|
||||
'page': result.page,
|
||||
'per_page': per_page,
|
||||
'pages': result.pages,
|
||||
})
|
||||
|
||||
|
||||
@audit_bp.route('/api/admin/audit/recording/<int:recording_id>', methods=['GET'])
|
||||
@admin_required
|
||||
def recording_access_logs(recording_id):
|
||||
"""Get all access logs for a specific recording."""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = min(request.args.get('per_page', 50, type=int), 200)
|
||||
|
||||
result = get_access_logs(page=page, per_page=per_page, resource_type='recording', resource_id=recording_id)
|
||||
|
||||
return jsonify({
|
||||
'logs': [log.to_dict() for log in result.items],
|
||||
'total': result.total,
|
||||
'page': result.page,
|
||||
'per_page': per_page,
|
||||
'pages': result.pages,
|
||||
})
|
||||
|
||||
|
||||
@audit_bp.route('/api/admin/audit/user/<int:user_id>', methods=['GET'])
|
||||
@admin_required
|
||||
def user_audit_logs(user_id):
|
||||
"""Get all audit logs (access + auth) for a specific user."""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = min(request.args.get('per_page', 50, type=int), 200)
|
||||
|
||||
access_result = get_access_logs(page=page, per_page=per_page, user_id=user_id)
|
||||
auth_result = get_auth_logs(page=page, per_page=per_page, user_id=user_id)
|
||||
|
||||
return jsonify({
|
||||
'access_logs': {
|
||||
'logs': [log.to_dict() for log in access_result.items],
|
||||
'total': access_result.total,
|
||||
},
|
||||
'auth_logs': {
|
||||
'logs': [log.to_dict() for log in auth_result.items],
|
||||
'total': auth_result.total,
|
||||
},
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
})
|
||||
886
src/api/auth.py
Normal file
886
src/api/auth.py
Normal file
@@ -0,0 +1,886 @@
|
||||
"""
|
||||
Authentication and user management routes.
|
||||
|
||||
This blueprint handles user registration, login, logout, account management,
|
||||
and password changes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import hashlib
|
||||
import mimetypes
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session, current_app
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, SubmitField, BooleanField
|
||||
from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from urllib.parse import urlparse, urljoin
|
||||
import markdown
|
||||
|
||||
from src.database import db
|
||||
from src.models import User, SystemSetting, GroupMembership
|
||||
from src.utils import password_check
|
||||
from src.auth.sso import (
|
||||
init_sso_client,
|
||||
is_sso_enabled,
|
||||
get_sso_config,
|
||||
get_sso_client,
|
||||
create_or_update_sso_user,
|
||||
is_domain_allowed,
|
||||
link_sso_to_existing_user,
|
||||
update_user_profile_from_claims,
|
||||
)
|
||||
from src.services.audit import (
|
||||
audit_login, audit_logout, audit_failed_login,
|
||||
audit_register, audit_password_change, audit_password_reset, audit_sso_login,
|
||||
)
|
||||
from src.services.email import (
|
||||
is_email_verification_enabled,
|
||||
is_email_verification_required,
|
||||
is_smtp_configured,
|
||||
send_verification_email,
|
||||
send_password_reset_email,
|
||||
verify_email_token,
|
||||
verify_reset_token,
|
||||
can_resend_verification,
|
||||
can_resend_password_reset,
|
||||
)
|
||||
|
||||
# Create blueprint
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
# Import these from app after initialization
|
||||
bcrypt = None
|
||||
csrf = None
|
||||
limiter = None
|
||||
|
||||
def init_auth_extensions(_bcrypt, _csrf, _limiter):
|
||||
"""Initialize extensions after app creation."""
|
||||
global bcrypt, csrf, limiter
|
||||
bcrypt = _bcrypt
|
||||
csrf = _csrf
|
||||
limiter = _limiter
|
||||
|
||||
|
||||
def rate_limit(limit_string):
|
||||
"""Decorator that applies rate limiting if limiter is available."""
|
||||
def decorator(f):
|
||||
from functools import wraps
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
return f(*args, **kwargs)
|
||||
# Store the limit string for later application
|
||||
wrapper._rate_limit = limit_string
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def csrf_exempt(f):
|
||||
"""Decorator placeholder for CSRF exemption - applied after initialization."""
|
||||
from functools import wraps
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
return f(*args, **kwargs)
|
||||
wrapper._csrf_exempt = True
|
||||
return wrapper
|
||||
|
||||
|
||||
# --- Forms ---
|
||||
|
||||
class RegistrationForm(FlaskForm):
|
||||
username = StringField('Username', validators=[DataRequired(), Length(min=2, max=20)])
|
||||
email = StringField('Email', validators=[DataRequired(), Email()])
|
||||
password = PasswordField('Password', validators=[DataRequired(), password_check])
|
||||
confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
|
||||
submit = SubmitField('Sign Up')
|
||||
|
||||
def validate_username(self, username):
|
||||
user = User.query.filter_by(username=username.data).first()
|
||||
if user:
|
||||
raise ValidationError('That username is already taken. Please choose a different one.')
|
||||
|
||||
def validate_email(self, email):
|
||||
user = User.query.filter_by(email=email.data).first()
|
||||
if user:
|
||||
raise ValidationError('That email is already registered. Please use a different one.')
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
email = StringField('Email', validators=[DataRequired(), Email()])
|
||||
password = PasswordField('Mot de passe', validators=[DataRequired()])
|
||||
remember = BooleanField('Se souvenir de moi')
|
||||
submit = SubmitField('Se connecter')
|
||||
|
||||
|
||||
# --- Helper Functions ---
|
||||
|
||||
def is_safe_url(target):
|
||||
ref_url = urlparse(request.host_url)
|
||||
test_url = urlparse(urljoin(request.host_url, target))
|
||||
return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc
|
||||
|
||||
|
||||
def is_registration_domain_allowed(email: str) -> bool:
|
||||
"""Check if email domain is allowed for registration.
|
||||
|
||||
Returns True if no domain restrictions are configured or if the
|
||||
email domain is in the allowed list.
|
||||
"""
|
||||
if not email:
|
||||
return False
|
||||
|
||||
domains_env = os.environ.get('REGISTRATION_ALLOWED_DOMAINS', '')
|
||||
if not domains_env or not domains_env.strip():
|
||||
return True # No restriction configured
|
||||
|
||||
allowed = [d.strip().lower() for d in domains_env.split(',') if d.strip()]
|
||||
if not allowed:
|
||||
return True # Empty after parsing
|
||||
|
||||
parts = email.lower().rsplit('@', 1)
|
||||
if len(parts) != 2:
|
||||
return False # Invalid email format
|
||||
|
||||
domain = parts[1]
|
||||
return domain in allowed
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
@rate_limit("10 per minute")
|
||||
def register():
|
||||
# Check if registration is allowed
|
||||
allow_registration = os.environ.get('ALLOW_REGISTRATION', 'true').lower() == 'true'
|
||||
|
||||
if not allow_registration:
|
||||
flash('Registration is currently disabled. Please contact the administrator.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('recordings.index'))
|
||||
|
||||
form = RegistrationForm()
|
||||
if form.validate_on_submit():
|
||||
# Check if email domain is allowed
|
||||
if not is_registration_domain_allowed(form.email.data):
|
||||
flash('Registration is restricted. Please contact the administrator.', 'danger')
|
||||
return render_template('register.html', title='Register', form=form)
|
||||
|
||||
hashed_password = bcrypt.generate_password_hash(form.password.data).decode('utf-8')
|
||||
|
||||
# Set email_verified based on whether verification is enabled
|
||||
# If verification is enabled, new users start unverified
|
||||
# If disabled, new users are considered verified by default
|
||||
email_verified = not is_email_verification_enabled()
|
||||
|
||||
user = User(
|
||||
username=form.username.data,
|
||||
email=form.email.data,
|
||||
password=hashed_password,
|
||||
email_verified=email_verified
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
audit_register(user.id)
|
||||
|
||||
# Send verification email if enabled
|
||||
if is_email_verification_enabled() and is_smtp_configured():
|
||||
if send_verification_email(user):
|
||||
return render_template('auth/check_email.html',
|
||||
title='Check Your Email',
|
||||
email=user.email,
|
||||
action='verification')
|
||||
else:
|
||||
# Email failed to send, but account was created
|
||||
flash('Your account has been created, but we could not send a verification email. Please contact support.', 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
flash('Your account has been created! You can now log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('register.html', title='Register', form=form)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
@rate_limit("10 per minute")
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('recordings.index'))
|
||||
|
||||
sso_enabled = is_sso_enabled()
|
||||
sso_config = get_sso_config()
|
||||
if sso_enabled:
|
||||
init_sso_client(current_app)
|
||||
|
||||
password_login_disabled = sso_enabled and sso_config.get('disable_password_login', False)
|
||||
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(email=form.email.data).first()
|
||||
if user and user.password:
|
||||
# Check if password login is disabled for non-admins
|
||||
if password_login_disabled and not user.is_admin:
|
||||
flash('Password login is disabled. Please sign in with SSO.', 'warning')
|
||||
elif bcrypt.check_password_hash(user.password, form.password.data):
|
||||
# Check email verification if required
|
||||
if is_email_verification_required() and not user.email_verified:
|
||||
# Store user email in session for resend functionality
|
||||
session['unverified_email'] = user.email
|
||||
return render_template('auth/check_email.html',
|
||||
title='Email Verification Required',
|
||||
email=user.email,
|
||||
action='verification_required',
|
||||
show_resend=True)
|
||||
|
||||
login_user(user, remember=form.remember.data)
|
||||
audit_login(user.id)
|
||||
next_page = request.args.get('next')
|
||||
if not is_safe_url(next_page):
|
||||
return redirect(url_for('recordings.index'))
|
||||
return redirect(next_page) if next_page else redirect(url_for('recordings.index'))
|
||||
else:
|
||||
_email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16]
|
||||
audit_failed_login(details={'email_hash': _email_hash, 'reason': 'wrong_password'})
|
||||
flash('Login unsuccessful. Please check email and password.', 'danger')
|
||||
elif user and not user.password:
|
||||
_email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16]
|
||||
audit_failed_login(details={'email_hash': _email_hash, 'reason': 'sso_only_account'})
|
||||
flash('This account uses SSO login. Please sign in with SSO.', 'warning')
|
||||
else:
|
||||
_email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16]
|
||||
audit_failed_login(details={'email_hash': _email_hash, 'reason': 'user_not_found'})
|
||||
flash('Login unsuccessful. Please check email and password.', 'danger')
|
||||
|
||||
return render_template(
|
||||
'login.html',
|
||||
title='Login',
|
||||
form=form,
|
||||
sso_enabled=sso_enabled,
|
||||
sso_provider_name=sso_config.get('provider_name', 'SSO'),
|
||||
password_login_disabled=password_login_disabled
|
||||
)
|
||||
|
||||
|
||||
@auth_bp.route('/auth/sso/login')
|
||||
@rate_limit("10 per minute")
|
||||
def sso_login():
|
||||
if not is_sso_enabled():
|
||||
flash('SSO is not configured. Please contact the administrator.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
oauth = get_sso_client() or init_sso_client(current_app)
|
||||
if not oauth:
|
||||
flash('Failed to initialize SSO client. Check server logs.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
next_url = request.args.get('next')
|
||||
if next_url and is_safe_url(next_url):
|
||||
session['sso_next'] = next_url
|
||||
else:
|
||||
session.pop('sso_next', None)
|
||||
|
||||
try:
|
||||
return oauth.sso.authorize_redirect(redirect_uri=get_sso_config().get('redirect_uri'))
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"SSO redirect failed: {e}")
|
||||
flash('Impossible de joindre le fournisseur SSO. Vérifiez la connexion réseau du serveur.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@auth_bp.route('/auth/sso/callback')
|
||||
@rate_limit("20 per minute")
|
||||
def sso_callback():
|
||||
if not is_sso_enabled():
|
||||
flash('SSO is not configured. Please contact the administrator.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
oauth = get_sso_client() or init_sso_client(current_app)
|
||||
if not oauth:
|
||||
flash('Failed to initialize SSO client. Check server logs.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
try:
|
||||
token = oauth.sso.authorize_access_token()
|
||||
userinfo = token.get('userinfo') or oauth.sso.userinfo()
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"SSO callback error: {e}")
|
||||
flash('SSO login failed. Please try again.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
subject = userinfo.get('sub')
|
||||
if not subject:
|
||||
flash('SSO response did not include a subject identifier.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
link_user_id = session.pop('sso_link_user_id', None)
|
||||
next_url = session.pop('sso_next', None)
|
||||
cfg = get_sso_config()
|
||||
|
||||
if link_user_id:
|
||||
target_user = db.session.get(User, int(link_user_id))
|
||||
if not target_user:
|
||||
flash('Could not link account: user not found.', 'danger')
|
||||
return redirect(url_for('auth.account'))
|
||||
|
||||
existing = User.query.filter_by(sso_subject=subject).first()
|
||||
if existing and existing.id != target_user.id:
|
||||
flash('This SSO account is already linked to another user.', 'danger')
|
||||
return redirect(url_for('auth.account'))
|
||||
|
||||
update_user_profile_from_claims(target_user, userinfo)
|
||||
target_user.sso_provider = cfg.get('provider_name', 'SSO')
|
||||
target_user.sso_subject = subject
|
||||
db.session.commit()
|
||||
flash('SSO account linked successfully.', 'success')
|
||||
return redirect(url_for('auth.account'))
|
||||
|
||||
try:
|
||||
user = create_or_update_sso_user(userinfo)
|
||||
except PermissionError as e:
|
||||
flash(str(e), 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
except ValueError as e:
|
||||
flash(str(e), 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"SSO login error: {e}")
|
||||
flash('Could not complete SSO login. Please try again.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
login_user(user, remember=True)
|
||||
audit_sso_login(user.id, details={'provider': cfg.get('provider_name', 'SSO')})
|
||||
|
||||
if next_url and is_safe_url(next_url):
|
||||
return redirect(next_url)
|
||||
return redirect(url_for('recordings.index'))
|
||||
|
||||
|
||||
@auth_bp.route('/auth/sso/link', methods=['POST'])
|
||||
@login_required
|
||||
def sso_link():
|
||||
if not is_sso_enabled():
|
||||
flash('SSO is not configured. Please contact the administrator.', 'danger')
|
||||
return redirect(url_for('auth.account'))
|
||||
|
||||
session['sso_link_user_id'] = current_user.id
|
||||
session['sso_next'] = url_for('auth.account')
|
||||
|
||||
return redirect(url_for('auth.sso_login'))
|
||||
|
||||
|
||||
@auth_bp.route('/auth/sso/unlink', methods=['POST'])
|
||||
@login_required
|
||||
def sso_unlink():
|
||||
if not current_user.sso_subject:
|
||||
flash('Your account is not linked to SSO.', 'warning')
|
||||
return redirect(url_for('auth.account'))
|
||||
|
||||
if not current_user.password:
|
||||
flash('Cannot unlink SSO - you have no password set. Please set a password first.', 'danger')
|
||||
return redirect(url_for('auth.account'))
|
||||
|
||||
current_user.sso_provider = None
|
||||
current_user.sso_subject = None
|
||||
db.session.commit()
|
||||
flash('SSO account unlinked successfully.', 'success')
|
||||
return redirect(url_for('auth.account'))
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@csrf_exempt
|
||||
def logout():
|
||||
if current_user.is_authenticated:
|
||||
audit_logout(current_user.id)
|
||||
logout_user()
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
# --- Email Verification Routes ---
|
||||
|
||||
@auth_bp.route('/verify-email/<token>')
|
||||
def verify_email(token):
|
||||
"""Verify email address using token from email link."""
|
||||
user_id = verify_email_token(token)
|
||||
|
||||
if user_id is None:
|
||||
flash('The verification link is invalid or has expired.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
flash('User not found.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
if user.email_verified:
|
||||
flash('Your email has already been verified.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# Verify the email
|
||||
user.email_verified = True
|
||||
user.email_verification_token = None # Clear the token
|
||||
db.session.commit()
|
||||
|
||||
return render_template('auth/verify_success.html', title='Email Verified')
|
||||
|
||||
|
||||
@auth_bp.route('/resend-verification', methods=['POST'])
|
||||
@rate_limit("3 per minute")
|
||||
def resend_verification():
|
||||
"""Resend verification email."""
|
||||
if not is_email_verification_enabled():
|
||||
flash('Email verification is not enabled.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
if not is_smtp_configured():
|
||||
flash('Email service is not configured.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# Get email from session (set during failed login) or form
|
||||
email = session.get('unverified_email') or request.form.get('email')
|
||||
|
||||
if not email:
|
||||
flash('Email address is required.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
user = User.query.filter_by(email=email).first()
|
||||
|
||||
if not user:
|
||||
# Don't reveal if user exists
|
||||
flash('If an account exists with this email, a verification link has been sent.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
if user.email_verified:
|
||||
flash('Your email has already been verified.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# Check cooldown
|
||||
can_resend, remaining = can_resend_verification(user)
|
||||
if not can_resend:
|
||||
flash(f'Please wait {remaining} seconds before requesting another verification email.', 'warning')
|
||||
return render_template('auth/check_email.html',
|
||||
title='Check Your Email',
|
||||
email=email,
|
||||
action='verification_required',
|
||||
show_resend=True)
|
||||
|
||||
if send_verification_email(user):
|
||||
flash('A new verification email has been sent.', 'success')
|
||||
else:
|
||||
flash('Failed to send verification email. Please try again later.', 'danger')
|
||||
|
||||
return render_template('auth/check_email.html',
|
||||
title='Check Your Email',
|
||||
email=email,
|
||||
action='verification',
|
||||
show_resend=True)
|
||||
|
||||
|
||||
# --- Password Reset Routes ---
|
||||
|
||||
@auth_bp.route('/forgot-password', methods=['GET', 'POST'])
|
||||
@rate_limit("5 per minute")
|
||||
def forgot_password():
|
||||
"""Show and handle forgot password form."""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('recordings.index'))
|
||||
|
||||
if not is_smtp_configured():
|
||||
flash('Password reset is not available. Please contact the administrator.', 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
if request.method == 'POST':
|
||||
email = request.form.get('email')
|
||||
|
||||
if not email:
|
||||
flash('Email address is required.', 'danger')
|
||||
return render_template('auth/forgot_password.html', title='Forgot Password')
|
||||
|
||||
user = User.query.filter_by(email=email).first()
|
||||
|
||||
# Always show the same message to prevent email enumeration
|
||||
if user:
|
||||
# Check if user has a password (not SSO-only)
|
||||
if user.password:
|
||||
# Check cooldown
|
||||
can_resend, remaining = can_resend_password_reset(user)
|
||||
if not can_resend:
|
||||
flash(f'Please wait {remaining} seconds before requesting another reset email.', 'warning')
|
||||
else:
|
||||
send_password_reset_email(user)
|
||||
|
||||
flash('If an account exists with this email, a password reset link has been sent.', 'info')
|
||||
return render_template('auth/check_email.html',
|
||||
title='Check Your Email',
|
||||
email=email,
|
||||
action='password_reset')
|
||||
|
||||
return render_template('auth/forgot_password.html', title='Forgot Password')
|
||||
|
||||
|
||||
@auth_bp.route('/reset-password/<token>', methods=['GET', 'POST'])
|
||||
@rate_limit("10 per minute")
|
||||
def reset_password(token):
|
||||
"""Handle password reset form."""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('recordings.index'))
|
||||
|
||||
user_id = verify_reset_token(token)
|
||||
|
||||
if user_id is None:
|
||||
flash('The password reset link is invalid or has expired.', 'danger')
|
||||
return redirect(url_for('auth.forgot_password'))
|
||||
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
flash('User not found.', 'danger')
|
||||
return redirect(url_for('auth.forgot_password'))
|
||||
|
||||
if request.method == 'POST':
|
||||
password = request.form.get('password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
|
||||
if not password or not confirm_password:
|
||||
flash('Both password fields are required.', 'danger')
|
||||
return render_template('auth/reset_password.html', title='Reset Password', token=token)
|
||||
|
||||
if password != confirm_password:
|
||||
flash('Passwords do not match.', 'danger')
|
||||
return render_template('auth/reset_password.html', title='Reset Password', token=token)
|
||||
|
||||
# Validate password
|
||||
try:
|
||||
password_check(None, type('obj', (object,), {'data': password}))
|
||||
except ValidationError as e:
|
||||
flash(str(e), 'danger')
|
||||
return render_template('auth/reset_password.html', title='Reset Password', token=token)
|
||||
|
||||
# Update password
|
||||
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
user.password = hashed_password
|
||||
user.password_reset_token = None # Clear the token
|
||||
user.password_reset_sent_at = None
|
||||
|
||||
# Also verify email if not already verified
|
||||
if not user.email_verified:
|
||||
user.email_verified = True
|
||||
|
||||
db.session.commit()
|
||||
audit_password_reset(user.id)
|
||||
|
||||
flash('Your password has been reset. You can now log in with your new password.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('auth/reset_password.html', title='Reset Password', token=token)
|
||||
|
||||
|
||||
@auth_bp.route('/account', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def account():
|
||||
# Import here to avoid circular imports
|
||||
from flask import current_app
|
||||
|
||||
if request.method == 'POST':
|
||||
# Only update fields that are present in the form submission
|
||||
# This prevents clearing data when switching between tabs
|
||||
|
||||
# Check if this is the account information form (has user_name field)
|
||||
if 'user_name' in request.form:
|
||||
# Handle personal information updates
|
||||
user_name = request.form.get('user_name')
|
||||
user_job_title = request.form.get('user_job_title')
|
||||
user_company = request.form.get('user_company')
|
||||
ui_lang = request.form.get('ui_language')
|
||||
transcription_lang = request.form.get('transcription_language')
|
||||
output_lang = request.form.get('output_language')
|
||||
|
||||
current_user.name = user_name if user_name else None
|
||||
current_user.job_title = user_job_title if user_job_title else None
|
||||
current_user.company = user_company if user_company else None
|
||||
current_user.ui_language = ui_lang if ui_lang else 'en'
|
||||
current_user.transcription_language = transcription_lang if transcription_lang else None
|
||||
current_user.output_language = output_lang if output_lang else None
|
||||
|
||||
# Check if this is the custom prompts form (has summary_prompt field)
|
||||
elif 'summary_prompt' in request.form:
|
||||
# Handle custom prompt updates
|
||||
summary_prompt_text = request.form.get('summary_prompt')
|
||||
current_user.summary_prompt = summary_prompt_text if summary_prompt_text else None
|
||||
# Handle event extraction setting
|
||||
current_user.extract_events = 'extract_events' in request.form
|
||||
# Handle transcription hints
|
||||
hotwords = request.form.get('transcription_hotwords')
|
||||
current_user.transcription_hotwords = hotwords if hotwords else None
|
||||
initial_prompt = request.form.get('transcription_initial_prompt')
|
||||
current_user.transcription_initial_prompt = initial_prompt if initial_prompt else None
|
||||
|
||||
# Only update diarize if it's not locked by env var
|
||||
if 'ASR_DIARIZE' not in os.environ:
|
||||
current_user.diarize = 'diarize' in request.form
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Return JSON response for AJAX requests
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.accept_mimetypes.best == 'application/json':
|
||||
return jsonify({'success': True, 'message': 'Account details updated successfully!'})
|
||||
|
||||
# Regular form submission with redirect
|
||||
flash('Account details updated successfully!', 'success')
|
||||
|
||||
# Preserve the active tab when redirecting
|
||||
if 'summary_prompt' in request.form:
|
||||
return redirect(url_for('auth.account') + '#prompts')
|
||||
else:
|
||||
return redirect(url_for('auth.account'))
|
||||
|
||||
# Get admin default prompt from system settings
|
||||
admin_default_prompt = SystemSetting.get_setting('admin_default_summary_prompt', None)
|
||||
if admin_default_prompt:
|
||||
default_summary_prompt_text = admin_default_prompt
|
||||
else:
|
||||
# Fallback to hardcoded default if admin hasn't set one
|
||||
default_summary_prompt_text = """Generate a comprehensive summary that includes the following sections:
|
||||
- **Key Issues Discussed**: A bulleted list of the main topics
|
||||
- **Key Decisions Made**: A bulleted list of any decisions reached
|
||||
- **Action Items**: A bulleted list of tasks assigned, including who is responsible if mentioned"""
|
||||
|
||||
asr_diarize_locked = 'ASR_DIARIZE' in os.environ
|
||||
ASR_DIARIZE = os.environ.get('ASR_DIARIZE', 'false').lower() == 'true'
|
||||
USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true'
|
||||
USE_NEW_TRANSCRIPTION_ARCHITECTURE = os.environ.get('USE_NEW_TRANSCRIPTION_ARCHITECTURE', 'true').lower() == 'true'
|
||||
ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true'
|
||||
ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true'
|
||||
ASR_RETURN_SPEAKER_EMBEDDINGS = os.environ.get('ASR_RETURN_SPEAKER_EMBEDDINGS', 'false').lower() == 'true'
|
||||
ENABLE_AUTO_EXPORT = os.environ.get('ENABLE_AUTO_EXPORT', 'false').lower() == 'true'
|
||||
|
||||
# Get connector diarization support (new architecture)
|
||||
connector_supports_diarization = USE_ASR_ENDPOINT # Default to USE_ASR_ENDPOINT for backwards compat
|
||||
if USE_NEW_TRANSCRIPTION_ARCHITECTURE:
|
||||
try:
|
||||
from src.services.transcription import get_registry
|
||||
registry = get_registry()
|
||||
connector = registry.get_active_connector()
|
||||
if connector:
|
||||
connector_supports_diarization = connector.supports_diarization
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"Could not get connector diarization support: {e}")
|
||||
|
||||
# Check if user is a team admin and get their admin groups
|
||||
admin_memberships = GroupMembership.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
role='admin'
|
||||
).all()
|
||||
|
||||
is_team_admin = len(admin_memberships) > 0
|
||||
|
||||
# Build list of groups where user is admin (for tag assignment)
|
||||
user_admin_groups = []
|
||||
for membership in admin_memberships:
|
||||
if membership.group:
|
||||
user_admin_groups.append({
|
||||
'id': membership.group.id,
|
||||
'name': membership.group.name
|
||||
})
|
||||
|
||||
sso_config = get_sso_config()
|
||||
sso_enabled = is_sso_enabled()
|
||||
if sso_enabled:
|
||||
init_sso_client(current_app)
|
||||
sso_linked = bool(current_user.sso_subject)
|
||||
|
||||
password_login_disabled = sso_enabled and sso_config.get('disable_password_login', False)
|
||||
|
||||
# Check if admin has globally disabled auto-summarization
|
||||
admin_setting = SystemSetting.get_setting('disable_auto_summarization', False)
|
||||
admin_disabled_auto_summarization = admin_setting if isinstance(admin_setting, bool) else str(admin_setting).lower() == 'true'
|
||||
|
||||
# Get user's UI language preference
|
||||
user_language = current_user.ui_language if current_user.ui_language else 'en'
|
||||
|
||||
return render_template('account.html',
|
||||
title='Account',
|
||||
default_summary_prompt_text=default_summary_prompt_text,
|
||||
use_asr_endpoint=USE_ASR_ENDPOINT,
|
||||
connector_supports_diarization=connector_supports_diarization,
|
||||
enable_auto_deletion=ENABLE_AUTO_DELETION,
|
||||
enable_internal_sharing=ENABLE_INTERNAL_SHARING,
|
||||
user_admin_groups=user_admin_groups,
|
||||
asr_diarize_locked=asr_diarize_locked,
|
||||
asr_diarize_env_value=ASR_DIARIZE,
|
||||
is_team_admin=is_team_admin,
|
||||
sso_enabled=sso_enabled,
|
||||
sso_provider_name=sso_config.get('provider_name', 'SSO'),
|
||||
sso_linked=sso_linked,
|
||||
sso_subject=current_user.sso_subject,
|
||||
has_password=bool(current_user.password),
|
||||
password_login_disabled=password_login_disabled,
|
||||
speaker_embeddings_enabled=ASR_RETURN_SPEAKER_EMBEDDINGS,
|
||||
auto_speaker_labelling=current_user.auto_speaker_labelling,
|
||||
auto_speaker_labelling_threshold=current_user.auto_speaker_labelling_threshold or 'medium',
|
||||
admin_disabled_auto_summarization=admin_disabled_auto_summarization,
|
||||
auto_summarization=current_user.auto_summarization if current_user.auto_summarization is not None else True,
|
||||
user_language=user_language,
|
||||
enable_auto_export=ENABLE_AUTO_EXPORT)
|
||||
|
||||
|
||||
@auth_bp.route('/api/user/auto-speaker-labelling', methods=['POST'])
|
||||
@login_required
|
||||
def update_auto_speaker_labelling():
|
||||
"""Update user's auto speaker labelling settings."""
|
||||
data = request.get_json()
|
||||
|
||||
if data is None:
|
||||
return jsonify({'success': False, 'error': 'Invalid JSON'}), 400
|
||||
|
||||
# Update enabled state
|
||||
if 'enabled' in data:
|
||||
current_user.auto_speaker_labelling = bool(data['enabled'])
|
||||
|
||||
# Update threshold (validate value)
|
||||
if 'threshold' in data:
|
||||
threshold = data['threshold']
|
||||
if threshold in ('low', 'medium', 'high'):
|
||||
current_user.auto_speaker_labelling_threshold = threshold
|
||||
else:
|
||||
return jsonify({'success': False, 'error': 'Invalid threshold value'}), 400
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'auto_speaker_labelling': current_user.auto_speaker_labelling,
|
||||
'auto_speaker_labelling_threshold': current_user.auto_speaker_labelling_threshold
|
||||
})
|
||||
|
||||
|
||||
@auth_bp.route('/api/user/auto-summarization', methods=['POST'])
|
||||
@login_required
|
||||
def update_auto_summarization():
|
||||
"""Update user's auto summarization setting."""
|
||||
data = request.get_json()
|
||||
|
||||
if data is None:
|
||||
return jsonify({'success': False, 'error': 'Invalid JSON'}), 400
|
||||
|
||||
if 'enabled' in data:
|
||||
current_user.auto_summarization = bool(data['enabled'])
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'auto_summarization': current_user.auto_summarization
|
||||
})
|
||||
|
||||
|
||||
@auth_bp.route('/change_password', methods=['POST'])
|
||||
@login_required
|
||||
@rate_limit("10 per minute")
|
||||
def change_password():
|
||||
# Check if password management is disabled for non-admins
|
||||
sso_config = get_sso_config()
|
||||
password_login_disabled = is_sso_enabled() and sso_config.get('disable_password_login', False)
|
||||
if password_login_disabled and not current_user.is_admin:
|
||||
flash('Password management is disabled. Please use SSO to sign in.', 'warning')
|
||||
return redirect(url_for('auth.account'))
|
||||
|
||||
current_password = request.form.get('current_password')
|
||||
new_password = request.form.get('new_password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
|
||||
# Check if user has an existing password
|
||||
has_existing_password = bool(current_user.password)
|
||||
|
||||
# Validate form data - current password only required if user has one
|
||||
if has_existing_password and not current_password:
|
||||
flash('Current password is required.', 'danger')
|
||||
return redirect(url_for('auth.account'))
|
||||
|
||||
if not new_password or not confirm_password:
|
||||
flash('New password and confirmation are required.', 'danger')
|
||||
return redirect(url_for('auth.account'))
|
||||
|
||||
if new_password != confirm_password:
|
||||
flash('New password and confirmation do not match.', 'danger')
|
||||
return redirect(url_for('auth.account'))
|
||||
|
||||
# Custom validation for new password
|
||||
try:
|
||||
password_check(None, type('obj', (object,), {'data': new_password}))
|
||||
except ValidationError as e:
|
||||
flash(str(e), 'danger')
|
||||
return redirect(url_for('auth.account'))
|
||||
|
||||
# Verify current password only if user has one
|
||||
if has_existing_password:
|
||||
if not bcrypt.check_password_hash(current_user.password, current_password):
|
||||
flash('Current password is incorrect.', 'danger')
|
||||
return redirect(url_for('auth.account'))
|
||||
|
||||
# Update password
|
||||
hashed_password = bcrypt.generate_password_hash(new_password).decode('utf-8')
|
||||
current_user.password = hashed_password
|
||||
db.session.commit()
|
||||
audit_password_change(current_user.id)
|
||||
|
||||
flash('Your password has been updated!', 'success')
|
||||
return redirect(url_for('auth.account'))
|
||||
|
||||
|
||||
@auth_bp.route('/docs/transcript-templates-guide')
|
||||
def transcript_templates_guide():
|
||||
"""Serve the transcript templates documentation."""
|
||||
from flask import current_app
|
||||
|
||||
docs_path = os.path.join(current_app.root_path, '..', 'docs', 'transcript-templates-guide.md')
|
||||
|
||||
if not os.path.exists(docs_path):
|
||||
return "Documentation not found", 404
|
||||
|
||||
with open(docs_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Convert markdown to HTML
|
||||
html_content = markdown.markdown(content, extensions=['tables', 'fenced_code', 'codehilite'])
|
||||
|
||||
# Wrap in basic HTML template with Speakr styling
|
||||
html_template = f'''
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Transcript Templates Guide - Speakr</title>
|
||||
<link rel="stylesheet" href="/static/css/output.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||||
<style>
|
||||
.markdown-body {{
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
.markdown-body h1 {{ font-size: 2.5rem; margin-bottom: 1rem; }}
|
||||
.markdown-body h2 {{ font-size: 2rem; margin-top: 2rem; margin-bottom: 1rem; }}
|
||||
.markdown-body h3 {{ font-size: 1.5rem; margin-top: 1.5rem; margin-bottom: 0.75rem; }}
|
||||
.markdown-body pre {{ background: #f4f4f4; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; }}
|
||||
.markdown-body code {{ background: #f4f4f4; padding: 0.2rem 0.4rem; border-radius: 0.25rem; }}
|
||||
.markdown-body pre code {{ background: none; padding: 0; }}
|
||||
.markdown-body ul, .markdown-body ol {{ margin-left: 2rem; margin-bottom: 1rem; }}
|
||||
.markdown-body li {{ margin-bottom: 0.5rem; }}
|
||||
.markdown-body blockquote {{ border-left: 4px solid #ddd; padding-left: 1rem; margin: 1rem 0; }}
|
||||
.markdown-body table {{ border-collapse: collapse; width: 100%; margin: 1rem 0; }}
|
||||
.markdown-body th, .markdown-body td {{ border: 1px solid #ddd; padding: 0.5rem; }}
|
||||
.markdown-body th {{ background: #f4f4f4; font-weight: bold; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="markdown-body">
|
||||
<a href="/" class="btn-primary" style="display: inline-block; margin-bottom: 1rem; padding: 0.5rem 1rem; background: #3b82f6; color: white; text-decoration: none; border-radius: 0.5rem;">← Back to App</a>
|
||||
{html_content}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
return html_template
|
||||
201
src/api/docs.py
Normal file
201
src/api/docs.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Documentation API - serves client documentation pages."""
|
||||
import os
|
||||
import markdown
|
||||
from markupsafe import escape
|
||||
from flask import Blueprint, jsonify
|
||||
from flask_login import login_required
|
||||
from src.utils import sanitize_html
|
||||
|
||||
docs_bp = Blueprint('docs', __name__)
|
||||
|
||||
# Path to client documentation files
|
||||
DOCS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'client_docs')
|
||||
|
||||
# Singleton Markdown instance (performance: avoid reinitializing extensions on every call)
|
||||
_docs_md = markdown.Markdown(
|
||||
extensions=[
|
||||
'tables',
|
||||
'fenced_code',
|
||||
'toc',
|
||||
'admonition',
|
||||
'attr_list',
|
||||
'md_in_html',
|
||||
'sane_lists'
|
||||
],
|
||||
extension_configs={
|
||||
'toc': {'permalink': False, 'toc_depth': 3}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _read_doc_file(filepath):
|
||||
"""Read and cache a documentation file. Returns content string."""
|
||||
mtime = os.path.getmtime(filepath)
|
||||
cache = getattr(_read_doc_file, '_cache', {})
|
||||
cached = cache.get(filepath)
|
||||
if cached and cached[0] == mtime:
|
||||
return cached[1]
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
cache[filepath] = (mtime, content)
|
||||
_read_doc_file._cache = cache
|
||||
return content
|
||||
|
||||
|
||||
def get_nav_structure(is_admin=False):
|
||||
"""Return navigation structure for documentation."""
|
||||
sections = [
|
||||
{
|
||||
"title": "Guide Utilisateur",
|
||||
"icon": "fa-book-open",
|
||||
"slug": "guide-utilisateur",
|
||||
"pages": [
|
||||
{"title": "Vue d'ensemble", "slug": "index"},
|
||||
{"title": "Premiers pas", "slug": "premiers-pas"},
|
||||
{"title": "Enregistrement", "slug": "enregistrement"},
|
||||
{"title": "Transcriptions", "slug": "transcriptions"},
|
||||
{"title": "Recherche IA", "slug": "recherche-ia"},
|
||||
{"title": "Partage", "slug": "partage"},
|
||||
{"title": "Dossiers", "slug": "dossiers"},
|
||||
{"title": "Groupes", "slug": "groupes"},
|
||||
{"title": "Paramètres", "slug": "parametres"},
|
||||
{"title": "Application mobile", "slug": "application-mobile"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Dépannage",
|
||||
"icon": "fa-life-ring",
|
||||
"slug": "depannage",
|
||||
"pages": [
|
||||
{"title": "Vue d'ensemble", "slug": "index"},
|
||||
{"title": "Transcription", "slug": "transcription"},
|
||||
{"title": "Performance", "slug": "performance"},
|
||||
{"title": "Fonctionnalités", "slug": "fonctionnalites"}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
if is_admin:
|
||||
sections.insert(1, {
|
||||
"title": "Guide Administrateur",
|
||||
"icon": "fa-shield-alt",
|
||||
"slug": "guide-admin",
|
||||
"pages": [
|
||||
{"title": "Vue d'ensemble", "slug": "index"},
|
||||
{"title": "Gestion des utilisateurs", "slug": "gestion-utilisateurs"},
|
||||
{"title": "Gestion des groupes", "slug": "gestion-groupes"},
|
||||
{"title": "Statistiques", "slug": "statistiques"},
|
||||
{"title": "Paramètres système", "slug": "parametres-systeme"},
|
||||
{"title": "Modèles IA", "slug": "modeles-ia"},
|
||||
{"title": "Prompts", "slug": "prompts"},
|
||||
{"title": "Recherche sémantique", "slug": "recherche-semantique"},
|
||||
{"title": "Rétention", "slug": "retention"},
|
||||
{"title": "SSO", "slug": "sso"}
|
||||
]
|
||||
})
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def render_markdown_content(md_text):
|
||||
"""Render markdown to sanitized HTML with extensions for admonitions, tables, etc."""
|
||||
_docs_md.reset()
|
||||
html = _docs_md.convert(md_text)
|
||||
toc_html = getattr(_docs_md, 'toc', '')
|
||||
# Sanitize rendered HTML to prevent XSS
|
||||
html = sanitize_html(html)
|
||||
return html, toc_html
|
||||
|
||||
|
||||
@docs_bp.route('/api/docs/nav')
|
||||
@login_required
|
||||
def docs_nav():
|
||||
"""Return navigation structure based on user role."""
|
||||
from flask_login import current_user
|
||||
is_admin = getattr(current_user, 'is_admin', False)
|
||||
sections = get_nav_structure(is_admin)
|
||||
return jsonify({"sections": sections})
|
||||
|
||||
|
||||
@docs_bp.route('/api/docs/page/<section>/<page>')
|
||||
@login_required
|
||||
def docs_page(section, page):
|
||||
"""Return rendered HTML for a documentation page."""
|
||||
# Sanitize path components to prevent directory traversal
|
||||
safe_section = os.path.basename(section)
|
||||
safe_page = os.path.basename(page)
|
||||
|
||||
allowed_sections = ['guide-utilisateur', 'guide-admin', 'depannage']
|
||||
if safe_section not in allowed_sections:
|
||||
return jsonify({"error": "Section invalide"}), 404
|
||||
|
||||
# Admin guide requires admin role
|
||||
if safe_section == 'guide-admin':
|
||||
from flask_login import current_user
|
||||
if not getattr(current_user, 'is_admin', False):
|
||||
return jsonify({"error": "Accès refusé"}), 403
|
||||
|
||||
filepath = os.path.join(DOCS_DIR, safe_section, f"{safe_page}.md")
|
||||
|
||||
if not os.path.isfile(filepath):
|
||||
return jsonify({"error": "Page non trouvée"}), 404
|
||||
|
||||
content = _read_doc_file(filepath)
|
||||
html, toc = render_markdown_content(content)
|
||||
|
||||
return jsonify({
|
||||
"html": html,
|
||||
"toc": toc,
|
||||
"section": safe_section,
|
||||
"page": safe_page
|
||||
})
|
||||
|
||||
|
||||
@docs_bp.route('/api/docs/search')
|
||||
@login_required
|
||||
def docs_search():
|
||||
"""Simple text search across all documentation pages."""
|
||||
from flask import request
|
||||
from flask_login import current_user
|
||||
|
||||
query = request.args.get('q', '').strip().lower()
|
||||
if not query or len(query) < 2:
|
||||
return jsonify({"results": []})
|
||||
|
||||
is_admin = getattr(current_user, 'is_admin', False)
|
||||
results = []
|
||||
|
||||
sections = get_nav_structure(is_admin)
|
||||
|
||||
for section in sections:
|
||||
section_dir = os.path.join(DOCS_DIR, section['slug'])
|
||||
if not os.path.isdir(section_dir):
|
||||
continue
|
||||
|
||||
for page_info in section['pages']:
|
||||
filepath = os.path.join(section_dir, f"{page_info['slug']}.md")
|
||||
if not os.path.isfile(filepath):
|
||||
continue
|
||||
|
||||
content = _read_doc_file(filepath)
|
||||
|
||||
content_lower = content.lower()
|
||||
if query in content_lower:
|
||||
# Find matching line for context
|
||||
lines = content.split('\n')
|
||||
snippet = ''
|
||||
for line in lines:
|
||||
if query in line.lower():
|
||||
# HTML-escape snippet to prevent XSS
|
||||
snippet = str(escape(line.strip()[:150]))
|
||||
break
|
||||
|
||||
results.append({
|
||||
"section": section['slug'],
|
||||
"section_title": section['title'],
|
||||
"page": page_info['slug'],
|
||||
"page_title": page_info['title'],
|
||||
"snippet": snippet
|
||||
})
|
||||
|
||||
return jsonify({"results": results})
|
||||
173
src/api/events.py
Normal file
173
src/api/events.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Calendar event extraction and export.
|
||||
|
||||
This blueprint was auto-generated from app.py route extraction.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, Response, current_app, make_response
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from src.database import db
|
||||
from src.models import *
|
||||
from src.utils import *
|
||||
from src.services.calendar import generate_ics_content
|
||||
|
||||
# Create blueprint
|
||||
events_bp = Blueprint('events', __name__)
|
||||
|
||||
# Configuration from environment
|
||||
ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true'
|
||||
ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true'
|
||||
USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true'
|
||||
ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true'
|
||||
USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true'
|
||||
|
||||
# Global helpers (will be injected from app)
|
||||
has_recording_access = None
|
||||
bcrypt = None
|
||||
csrf = None
|
||||
limiter = None
|
||||
|
||||
def init_events_helpers(**kwargs):
|
||||
"""Initialize helper functions and extensions from app."""
|
||||
global has_recording_access, bcrypt, csrf, limiter
|
||||
has_recording_access = kwargs.get('has_recording_access')
|
||||
bcrypt = kwargs.get('bcrypt')
|
||||
csrf = kwargs.get('csrf')
|
||||
limiter = kwargs.get('limiter')
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@events_bp.route('/api/recording/<int:recording_id>/events', methods=['GET'])
|
||||
@login_required
|
||||
def get_recording_events(recording_id):
|
||||
"""Get all events extracted from a recording."""
|
||||
try:
|
||||
recording = db.session.get(Recording, recording_id)
|
||||
if not recording:
|
||||
return jsonify({'error': 'Recording not found'}), 404
|
||||
|
||||
if not has_recording_access(recording, current_user):
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
events = Event.query.filter_by(recording_id=recording_id).all()
|
||||
return jsonify({'events': [event.to_dict() for event in events]})
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error fetching events for recording {recording_id}: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@events_bp.route('/api/event/<int:event_id>/ics', methods=['GET'])
|
||||
@login_required
|
||||
def download_event_ics(event_id):
|
||||
"""Generate and download an ICS file for a single event."""
|
||||
try:
|
||||
event = db.session.get(Event, event_id)
|
||||
if not event:
|
||||
return jsonify({'error': 'Event not found'}), 404
|
||||
|
||||
# Check permissions through recording access
|
||||
if not has_recording_access(event.recording, current_user):
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
# Generate ICS content
|
||||
ics_content = generate_ics_content(event)
|
||||
|
||||
# Create response with ICS file
|
||||
response = make_response(ics_content)
|
||||
response.headers['Content-Type'] = 'text/calendar; charset=utf-8'
|
||||
response.headers['Content-Disposition'] = f'attachment; filename="{secure_filename(event.title)}.ics"'
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error generating ICS for event {event_id}: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@events_bp.route('/api/recording/<int:recording_id>/events/ics', methods=['GET'])
|
||||
@login_required
|
||||
def download_all_events_ics(recording_id):
|
||||
"""Generate and download an ICS file containing all events from a recording."""
|
||||
try:
|
||||
recording = db.session.get(Recording, recording_id)
|
||||
if not recording:
|
||||
return jsonify({'error': 'Recording not found'}), 404
|
||||
|
||||
if not has_recording_access(recording, current_user):
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
# Get all events for this recording
|
||||
events = Event.query.filter_by(recording_id=recording_id).all()
|
||||
if not events:
|
||||
return jsonify({'error': 'No events found for this recording'}), 404
|
||||
|
||||
# Generate combined ICS content
|
||||
ics_lines = []
|
||||
ics_lines.append("BEGIN:VCALENDAR")
|
||||
ics_lines.append("VERSION:2.0")
|
||||
ics_lines.append("PRODID:-//Speakr//Event Export//EN")
|
||||
ics_lines.append("CALSCALE:GREGORIAN")
|
||||
ics_lines.append("METHOD:PUBLISH")
|
||||
|
||||
# Add each event
|
||||
for event in events:
|
||||
# Get the individual event's ICS content and extract just the VEVENT portion
|
||||
individual_ics = generate_ics_content(event)
|
||||
# Extract VEVENT block from individual ICS
|
||||
lines = individual_ics.split('\n')
|
||||
in_event = False
|
||||
for line in lines:
|
||||
if line.startswith('BEGIN:VEVENT'):
|
||||
in_event = True
|
||||
if in_event:
|
||||
ics_lines.append(line)
|
||||
if line.startswith('END:VEVENT'):
|
||||
in_event = False
|
||||
|
||||
ics_lines.append("END:VCALENDAR")
|
||||
ics_content = '\r\n'.join(ics_lines)
|
||||
|
||||
# Create response with ICS file
|
||||
response = make_response(ics_content)
|
||||
response.headers['Content-Type'] = 'text/calendar; charset=utf-8'
|
||||
safe_title = secure_filename(recording.title) if recording.title else f'recording-{recording_id}'
|
||||
response.headers['Content-Disposition'] = f'attachment; filename="{safe_title}-events.ics"'
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error generating ICS for all events in recording {recording_id}: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@events_bp.route('/api/event/<int:event_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_event(event_id):
|
||||
"""Delete a single event."""
|
||||
try:
|
||||
event = db.session.get(Event, event_id)
|
||||
if not event:
|
||||
return jsonify({'error': 'Event not found'}), 404
|
||||
|
||||
# Check permissions through recording access
|
||||
if not has_recording_access(event.recording, current_user):
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
db.session.delete(event)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error deleting event {event_id}: {e}")
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
162
src/api/export_templates.py
Normal file
162
src/api/export_templates.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Export template management API.
|
||||
|
||||
This blueprint provides CRUD operations for export templates,
|
||||
following the same pattern as transcript templates.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from src.database import db
|
||||
from src.models import ExportTemplate
|
||||
|
||||
# Create blueprint
|
||||
export_templates_bp = Blueprint('export_templates', __name__)
|
||||
|
||||
# Configuration from environment
|
||||
ENABLE_AUTO_EXPORT = os.environ.get('ENABLE_AUTO_EXPORT', 'false').lower() == 'true'
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@export_templates_bp.route('/api/export-templates', methods=['GET'])
|
||||
@login_required
|
||||
def get_export_templates():
|
||||
"""Get all export templates for the current user."""
|
||||
templates = ExportTemplate.query.filter_by(user_id=current_user.id).all()
|
||||
return jsonify([template.to_dict() for template in templates])
|
||||
|
||||
|
||||
@export_templates_bp.route('/api/export-templates', methods=['POST'])
|
||||
@login_required
|
||||
def create_export_template():
|
||||
"""Create a new export template."""
|
||||
data = request.json
|
||||
if not data or not data.get('name') or not data.get('template'):
|
||||
return jsonify({'error': 'Name and template are required'}), 400
|
||||
|
||||
# If this is set as default, unset other defaults
|
||||
if data.get('is_default'):
|
||||
ExportTemplate.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
is_default=True
|
||||
).update({'is_default': False})
|
||||
|
||||
template = ExportTemplate(
|
||||
user_id=current_user.id,
|
||||
name=data['name'],
|
||||
template=data['template'],
|
||||
description=data.get('description'),
|
||||
is_default=data.get('is_default', False)
|
||||
)
|
||||
|
||||
db.session.add(template)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(template.to_dict()), 201
|
||||
|
||||
|
||||
@export_templates_bp.route('/api/export-templates/<int:template_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_export_template(template_id):
|
||||
"""Update an existing export template."""
|
||||
template = ExportTemplate.query.filter_by(
|
||||
id=template_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
return jsonify({'error': 'Template not found'}), 404
|
||||
|
||||
data = request.json
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
# If this is set as default, unset other defaults
|
||||
if data.get('is_default'):
|
||||
ExportTemplate.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
is_default=True
|
||||
).update({'is_default': False})
|
||||
|
||||
template.name = data.get('name', template.name)
|
||||
template.template = data.get('template', template.template)
|
||||
template.description = data.get('description', template.description)
|
||||
template.is_default = data.get('is_default', template.is_default)
|
||||
template.updated_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(template.to_dict())
|
||||
|
||||
|
||||
@export_templates_bp.route('/api/export-templates/<int:template_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_export_template(template_id):
|
||||
"""Delete an export template."""
|
||||
template = ExportTemplate.query.filter_by(
|
||||
id=template_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
return jsonify({'error': 'Template not found'}), 404
|
||||
|
||||
db.session.delete(template)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@export_templates_bp.route('/api/export-templates/create-defaults', methods=['POST'])
|
||||
@login_required
|
||||
def create_default_export_templates():
|
||||
"""Create default export template for the user if they don't have any."""
|
||||
existing_templates = ExportTemplate.query.filter_by(user_id=current_user.id).count()
|
||||
|
||||
if existing_templates > 0:
|
||||
return jsonify({'message': 'User already has templates'}), 200
|
||||
|
||||
# Default template with localized labels
|
||||
default_template = ExportTemplate(
|
||||
user_id=current_user.id,
|
||||
name="Standard Export",
|
||||
template="""# {{title}}
|
||||
|
||||
## {{label.metadata}}
|
||||
|
||||
{{#if meeting_date}}- **{{label.date}}:** {{meeting_date}}
|
||||
{{/if}}{{#if created_at}}- **{{label.created}}:** {{created_at}}
|
||||
{{/if}}{{#if original_filename}}- **{{label.originalFile}}:** {{original_filename}}
|
||||
{{/if}}{{#if file_size}}- **{{label.fileSize}}:** {{file_size}}
|
||||
{{/if}}{{#if participants}}- **{{label.participants}}:** {{participants}}
|
||||
{{/if}}{{#if tags}}- **{{label.tags}}:** {{tags}}
|
||||
{{/if}}
|
||||
|
||||
{{#if notes}}## {{label.notes}}
|
||||
|
||||
{{notes}}
|
||||
|
||||
{{/if}}{{#if summary}}## {{label.summary}}
|
||||
|
||||
{{summary}}
|
||||
|
||||
{{/if}}{{#if transcription}}## {{label.transcription}}
|
||||
|
||||
{{transcription}}
|
||||
|
||||
{{/if}}""",
|
||||
description="Default export template with localized labels",
|
||||
is_default=True
|
||||
)
|
||||
|
||||
db.session.add(default_template)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'templates': [default_template.to_dict()]
|
||||
}), 201
|
||||
665
src/api/folders.py
Normal file
665
src/api/folders.py
Normal file
@@ -0,0 +1,665 @@
|
||||
"""
|
||||
Folder management and assignment.
|
||||
|
||||
This blueprint handles folder CRUD operations and recording-folder assignments.
|
||||
Folders are one-to-many (a recording can only belong to one folder).
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from src.database import db
|
||||
from src.services.audit import audit_access
|
||||
from src.models import *
|
||||
|
||||
# Create blueprint
|
||||
folders_bp = Blueprint('folders', __name__)
|
||||
|
||||
# Configuration from environment
|
||||
ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true'
|
||||
ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true'
|
||||
|
||||
# Global helpers (will be injected from app)
|
||||
has_recording_access = None
|
||||
bcrypt = None
|
||||
csrf = None
|
||||
limiter = None
|
||||
|
||||
|
||||
def init_folders_helpers(**kwargs):
|
||||
"""Initialize helper functions and extensions from app."""
|
||||
global has_recording_access, bcrypt, csrf, limiter
|
||||
has_recording_access = kwargs.get('has_recording_access')
|
||||
bcrypt = kwargs.get('bcrypt')
|
||||
csrf = kwargs.get('csrf')
|
||||
limiter = kwargs.get('limiter')
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@folders_bp.route('/api/folders', methods=['GET'])
|
||||
@login_required
|
||||
def get_folders():
|
||||
"""Get all folders for the current user, including group folders they have access to."""
|
||||
# Check if folders feature is enabled - return empty array if not
|
||||
folders_enabled = SystemSetting.get_setting('enable_folders', False)
|
||||
if not folders_enabled:
|
||||
return jsonify([])
|
||||
|
||||
# Get user's personal folders
|
||||
user_folders = Folder.query.filter_by(user_id=current_user.id, group_id=None).order_by(Folder.name).all()
|
||||
|
||||
# Get user's team memberships with roles
|
||||
memberships = GroupMembership.query.filter_by(user_id=current_user.id).all()
|
||||
team_roles = {m.group_id: m.role for m in memberships}
|
||||
team_ids = list(team_roles.keys())
|
||||
|
||||
# Get group folders for all teams the user is a member of
|
||||
team_folders = []
|
||||
if team_ids:
|
||||
team_folders = Folder.query.filter(Folder.group_id.in_(team_ids)).order_by(Folder.name).all()
|
||||
|
||||
# Build response with edit permissions
|
||||
result = []
|
||||
|
||||
# Personal folders - user can always edit their own
|
||||
for folder in user_folders:
|
||||
folder_dict = folder.to_dict()
|
||||
folder_dict['can_edit'] = True
|
||||
folder_dict['user_role'] = None
|
||||
result.append(folder_dict)
|
||||
|
||||
# Group folders - only admins can edit
|
||||
for folder in team_folders:
|
||||
folder_dict = folder.to_dict()
|
||||
user_role = team_roles.get(folder.group_id, 'member')
|
||||
folder_dict['can_edit'] = (user_role == 'admin')
|
||||
folder_dict['user_role'] = user_role
|
||||
result.append(folder_dict)
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@folders_bp.route('/api/folders', methods=['POST'])
|
||||
@login_required
|
||||
def create_folder():
|
||||
"""Create a new folder (personal or group folder)."""
|
||||
# Check if folders feature is enabled
|
||||
folders_enabled = SystemSetting.get_setting('enable_folders', False)
|
||||
if not folders_enabled:
|
||||
return jsonify({'error': 'Folders feature is not enabled'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('name'):
|
||||
return jsonify({'error': 'Folder name is required'}), 400
|
||||
|
||||
group_id = data.get('group_id')
|
||||
|
||||
# If creating a group folder, verify user is admin of that group
|
||||
if group_id:
|
||||
membership = GroupMembership.query.filter_by(
|
||||
group_id=group_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not membership or membership.role != 'admin':
|
||||
return jsonify({'error': 'Only group admins can create group folders'}), 403
|
||||
|
||||
# Check if group folder with same name already exists for this group
|
||||
existing_folder = Folder.query.filter_by(name=data['name'], group_id=group_id).first()
|
||||
if existing_folder:
|
||||
return jsonify({'error': 'A folder with this name already exists for this group'}), 400
|
||||
else:
|
||||
# Check if personal folder with same name already exists for this user
|
||||
existing_folder = Folder.query.filter_by(name=data['name'], user_id=current_user.id, group_id=None).first()
|
||||
if existing_folder:
|
||||
return jsonify({'error': 'Folder with this name already exists'}), 400
|
||||
|
||||
# Handle retention_days: -1 means protected from deletion
|
||||
retention_days = data.get('retention_days')
|
||||
protect_from_deletion = False
|
||||
|
||||
if retention_days == -1:
|
||||
# -1 indicates infinite retention (protected from auto-deletion)
|
||||
protect_from_deletion = True if ENABLE_AUTO_DELETION else False
|
||||
|
||||
# Validate naming_template_id if provided
|
||||
naming_template_id = data.get('naming_template_id')
|
||||
if naming_template_id:
|
||||
template = NamingTemplate.query.filter_by(id=naming_template_id, user_id=current_user.id).first()
|
||||
if not template:
|
||||
return jsonify({'error': 'Naming template not found'}), 404
|
||||
|
||||
# Validate export_template_id if provided
|
||||
export_template_id = data.get('export_template_id')
|
||||
if export_template_id:
|
||||
template = ExportTemplate.query.filter_by(id=export_template_id, user_id=current_user.id).first()
|
||||
if not template:
|
||||
return jsonify({'error': 'Export template not found'}), 404
|
||||
|
||||
folder = Folder(
|
||||
name=data['name'],
|
||||
user_id=current_user.id,
|
||||
group_id=group_id,
|
||||
color=data.get('color', '#10B981'),
|
||||
custom_prompt=data.get('custom_prompt'),
|
||||
default_language=data.get('default_language'),
|
||||
default_min_speakers=data.get('default_min_speakers'),
|
||||
default_max_speakers=data.get('default_max_speakers'),
|
||||
default_hotwords=data.get('default_hotwords'),
|
||||
default_initial_prompt=data.get('default_initial_prompt'),
|
||||
protect_from_deletion=protect_from_deletion,
|
||||
retention_days=retention_days,
|
||||
auto_share_on_apply=data.get('auto_share_on_apply', True) if group_id else True,
|
||||
share_with_group_lead=data.get('share_with_group_lead', True) if group_id else True,
|
||||
naming_template_id=naming_template_id,
|
||||
export_template_id=export_template_id
|
||||
)
|
||||
|
||||
db.session.add(folder)
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Folder creation failed due to integrity constraint: {str(e)}")
|
||||
return jsonify({'error': 'A folder with this name already exists'}), 400
|
||||
|
||||
return jsonify(folder.to_dict()), 201
|
||||
|
||||
|
||||
@folders_bp.route('/api/folders/<int:folder_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_folder(folder_id):
|
||||
"""Update a folder."""
|
||||
# Check if folders feature is enabled
|
||||
folders_enabled = SystemSetting.get_setting('enable_folders', False)
|
||||
if not folders_enabled:
|
||||
return jsonify({'error': 'Folders feature is not enabled'}), 403
|
||||
|
||||
folder = db.session.get(Folder, folder_id)
|
||||
if not folder:
|
||||
return jsonify({'error': 'Folder not found'}), 404
|
||||
|
||||
# Check permissions
|
||||
if folder.group_id:
|
||||
# Group folder - user must be a team admin
|
||||
membership = GroupMembership.query.filter_by(
|
||||
group_id=folder.group_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not membership or membership.role != 'admin':
|
||||
return jsonify({'error': 'Only group admins can edit group folders'}), 403
|
||||
else:
|
||||
# Personal folder - must be the owner
|
||||
if folder.user_id != current_user.id:
|
||||
return jsonify({'error': 'You do not have permission to edit this folder'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
if 'name' in data:
|
||||
# Check if new name conflicts with another folder
|
||||
if folder.group_id:
|
||||
existing_folder = Folder.query.filter_by(name=data['name'], group_id=folder.group_id).filter(Folder.id != folder_id).first()
|
||||
else:
|
||||
existing_folder = Folder.query.filter_by(name=data['name'], user_id=current_user.id).filter(Folder.id != folder_id).first()
|
||||
|
||||
if existing_folder:
|
||||
return jsonify({'error': 'Another folder with this name already exists'}), 400
|
||||
folder.name = data['name']
|
||||
|
||||
# Handle group_id changes (converting between personal and group folders)
|
||||
if 'group_id' in data:
|
||||
new_group_id = data['group_id'] if data['group_id'] else None
|
||||
|
||||
# If changing to a group folder, verify user is admin of that group
|
||||
if new_group_id:
|
||||
membership = GroupMembership.query.filter_by(
|
||||
group_id=new_group_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not membership or membership.role != 'admin':
|
||||
return jsonify({'error': 'Only group admins can assign folders to groups'}), 403
|
||||
|
||||
folder.group_id = new_group_id
|
||||
|
||||
if 'color' in data:
|
||||
folder.color = data['color']
|
||||
if 'custom_prompt' in data:
|
||||
folder.custom_prompt = data['custom_prompt']
|
||||
if 'default_language' in data:
|
||||
folder.default_language = data['default_language']
|
||||
if 'default_min_speakers' in data:
|
||||
folder.default_min_speakers = data['default_min_speakers']
|
||||
if 'default_max_speakers' in data:
|
||||
folder.default_max_speakers = data['default_max_speakers']
|
||||
if 'default_hotwords' in data:
|
||||
folder.default_hotwords = data['default_hotwords'] or None
|
||||
if 'default_initial_prompt' in data:
|
||||
folder.default_initial_prompt = data['default_initial_prompt'] or None
|
||||
|
||||
# Handle retention_days: -1 means protected from deletion
|
||||
if 'retention_days' in data:
|
||||
retention_days = data['retention_days']
|
||||
|
||||
if retention_days == -1:
|
||||
# -1 indicates infinite retention (protected from auto-deletion)
|
||||
if ENABLE_AUTO_DELETION:
|
||||
folder.protect_from_deletion = True
|
||||
folder.retention_days = -1
|
||||
else:
|
||||
# Regular retention period or null (use global)
|
||||
folder.protect_from_deletion = False
|
||||
folder.retention_days = retention_days if retention_days else None
|
||||
if 'auto_share_on_apply' in data:
|
||||
# Only applicable to group folders
|
||||
if folder.group_id:
|
||||
folder.auto_share_on_apply = bool(data['auto_share_on_apply'])
|
||||
if 'share_with_group_lead' in data:
|
||||
# Only applicable to group folders
|
||||
if folder.group_id:
|
||||
folder.share_with_group_lead = bool(data['share_with_group_lead'])
|
||||
if 'naming_template_id' in data:
|
||||
naming_template_id = data['naming_template_id']
|
||||
if naming_template_id:
|
||||
template = NamingTemplate.query.filter_by(id=naming_template_id, user_id=current_user.id).first()
|
||||
if not template:
|
||||
return jsonify({'error': 'Naming template not found'}), 404
|
||||
folder.naming_template_id = naming_template_id if naming_template_id else None
|
||||
if 'export_template_id' in data:
|
||||
export_template_id = data['export_template_id']
|
||||
if export_template_id:
|
||||
template = ExportTemplate.query.filter_by(id=export_template_id, user_id=current_user.id).first()
|
||||
if not template:
|
||||
return jsonify({'error': 'Export template not found'}), 404
|
||||
folder.export_template_id = export_template_id if export_template_id else None
|
||||
|
||||
folder.updated_at = datetime.utcnow()
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Folder update failed due to integrity constraint: {str(e)}")
|
||||
return jsonify({'error': 'A folder with this name already exists'}), 400
|
||||
|
||||
return jsonify(folder.to_dict())
|
||||
|
||||
|
||||
@folders_bp.route('/api/folders/<int:folder_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_folder(folder_id):
|
||||
"""Delete a folder. Recordings in this folder will have folder_id set to NULL."""
|
||||
# Check if folders feature is enabled
|
||||
folders_enabled = SystemSetting.get_setting('enable_folders', False)
|
||||
if not folders_enabled:
|
||||
return jsonify({'error': 'Folders feature is not enabled'}), 403
|
||||
|
||||
folder = db.session.get(Folder, folder_id)
|
||||
if not folder:
|
||||
return jsonify({'error': 'Folder not found'}), 404
|
||||
|
||||
# Check permissions
|
||||
if folder.group_id:
|
||||
# Group folder - user must be a team admin
|
||||
membership = GroupMembership.query.filter_by(
|
||||
group_id=folder.group_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not membership or membership.role != 'admin':
|
||||
return jsonify({'error': 'Only group admins can delete group folders'}), 403
|
||||
else:
|
||||
# Personal folder - must belong to the user
|
||||
if folder.user_id != current_user.id:
|
||||
return jsonify({'error': 'You do not have permission to delete this folder'}), 403
|
||||
|
||||
# Recordings in this folder will have folder_id set to NULL via ondelete='SET NULL'
|
||||
db.session.delete(folder)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@folders_bp.route('/api/groups/<int:group_id>/folders', methods=['POST'])
|
||||
@login_required
|
||||
def create_group_folder(group_id):
|
||||
"""Create a group-scoped folder (group admins only)."""
|
||||
# Check if folders feature is enabled
|
||||
folders_enabled = SystemSetting.get_setting('enable_folders', False)
|
||||
if not folders_enabled:
|
||||
return jsonify({'error': 'Folders feature is not enabled'}), 403
|
||||
|
||||
if not ENABLE_INTERNAL_SHARING:
|
||||
return jsonify({'error': 'Group folders require internal sharing to be enabled. Please set ENABLE_INTERNAL_SHARING=true in your configuration.'}), 403
|
||||
|
||||
# Verify team exists
|
||||
team = db.session.get(Group, group_id)
|
||||
if not team:
|
||||
return jsonify({'error': 'Group not found'}), 404
|
||||
|
||||
# Verify user is a team admin
|
||||
membership = GroupMembership.query.filter_by(
|
||||
group_id=group_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not membership or membership.role != 'admin':
|
||||
return jsonify({'error': 'Only group admins can create group folders'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': 'Folder name is required'}), 400
|
||||
|
||||
# Check if a group folder with this name already exists for this team
|
||||
existing_folder = Folder.query.filter_by(
|
||||
name=name,
|
||||
group_id=group_id
|
||||
).first()
|
||||
|
||||
if existing_folder:
|
||||
return jsonify({'error': 'A group folder with this name already exists'}), 400
|
||||
|
||||
# Validate naming_template_id if provided
|
||||
naming_template_id = data.get('naming_template_id')
|
||||
if naming_template_id:
|
||||
template = NamingTemplate.query.filter_by(id=naming_template_id, user_id=current_user.id).first()
|
||||
if not template:
|
||||
return jsonify({'error': 'Naming template not found'}), 404
|
||||
|
||||
# Validate export_template_id if provided
|
||||
export_template_id = data.get('export_template_id')
|
||||
if export_template_id:
|
||||
template = ExportTemplate.query.filter_by(id=export_template_id, user_id=current_user.id).first()
|
||||
if not template:
|
||||
return jsonify({'error': 'Export template not found'}), 404
|
||||
|
||||
# Create the group folder with all supported parameters
|
||||
folder = Folder(
|
||||
name=name,
|
||||
user_id=current_user.id, # Creator
|
||||
group_id=group_id,
|
||||
color=data.get('color', '#10B981'),
|
||||
custom_prompt=data.get('custom_prompt'),
|
||||
default_language=data.get('default_language'),
|
||||
default_min_speakers=data.get('default_min_speakers'),
|
||||
default_max_speakers=data.get('default_max_speakers'),
|
||||
default_hotwords=data.get('default_hotwords'),
|
||||
default_initial_prompt=data.get('default_initial_prompt'),
|
||||
protect_from_deletion=data.get('protect_from_deletion', False),
|
||||
retention_days=data.get('retention_days'),
|
||||
auto_share_on_apply=data.get('auto_share_on_apply', True), # Default to True for group folders
|
||||
share_with_group_lead=data.get('share_with_group_lead', True), # Default to True for group folders
|
||||
naming_template_id=naming_template_id,
|
||||
export_template_id=export_template_id
|
||||
)
|
||||
|
||||
db.session.add(folder)
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Folder creation failed due to integrity constraint: {str(e)}")
|
||||
return jsonify({'error': 'A folder with this name already exists'}), 400
|
||||
|
||||
return jsonify(folder.to_dict()), 201
|
||||
|
||||
|
||||
@folders_bp.route('/api/groups/<int:group_id>/folders', methods=['GET'])
|
||||
@login_required
|
||||
def get_group_folders(group_id):
|
||||
"""Get all folders for a team (team members only)."""
|
||||
# Check if folders feature is enabled
|
||||
folders_enabled = SystemSetting.get_setting('enable_folders', False)
|
||||
if not folders_enabled:
|
||||
return jsonify({'error': 'Folders feature is not enabled'}), 403
|
||||
|
||||
# Verify team exists
|
||||
team = db.session.get(Group, group_id)
|
||||
if not team:
|
||||
return jsonify({'error': 'Group not found'}), 404
|
||||
|
||||
# Verify user is a team member
|
||||
membership = GroupMembership.query.filter_by(
|
||||
group_id=group_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not membership:
|
||||
return jsonify({'error': 'You must be a team member to view group folders'}), 403
|
||||
|
||||
# Get all group folders
|
||||
folders = Folder.query.filter_by(group_id=group_id).all()
|
||||
|
||||
return jsonify({'folders': [folder.to_dict() for folder in folders]})
|
||||
|
||||
|
||||
@folders_bp.route('/api/recordings/<int:recording_id>/folder', methods=['PUT'])
|
||||
@login_required
|
||||
def assign_recording_folder(recording_id):
|
||||
"""Assign a recording to a folder (or move to a different folder)."""
|
||||
# Check if folders feature is enabled
|
||||
folders_enabled = SystemSetting.get_setting('enable_folders', False)
|
||||
if not folders_enabled:
|
||||
return jsonify({'error': 'Folders feature is not enabled'}), 403
|
||||
|
||||
recording = db.session.get(Recording, recording_id)
|
||||
if not recording:
|
||||
return jsonify({'error': 'Recording not found'}), 404
|
||||
|
||||
# Check access to recording (require edit permission)
|
||||
if has_recording_access:
|
||||
if not has_recording_access(recording, current_user, require_edit=True):
|
||||
return jsonify({'error': 'You do not have permission to modify this recording'}), 403
|
||||
else:
|
||||
# Fallback: only owner can assign folder
|
||||
if recording.user_id != current_user.id:
|
||||
return jsonify({'error': 'You do not have permission to modify this recording'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
folder_id = data.get('folder_id')
|
||||
|
||||
if folder_id:
|
||||
# Verify folder exists and user has access
|
||||
folder = db.session.get(Folder, folder_id)
|
||||
if not folder:
|
||||
return jsonify({'error': 'Folder not found'}), 404
|
||||
|
||||
# Check if user can use this folder
|
||||
if folder.group_id:
|
||||
# Group folder - user must be a member
|
||||
membership = GroupMembership.query.filter_by(
|
||||
group_id=folder.group_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
if not membership:
|
||||
return jsonify({'error': 'You do not have access to this folder'}), 403
|
||||
else:
|
||||
# Personal folder - must be owner
|
||||
if folder.user_id != current_user.id:
|
||||
return jsonify({'error': 'You do not have access to this folder'}), 403
|
||||
|
||||
# Handle auto-sharing for group folders
|
||||
old_folder_id = recording.folder_id
|
||||
recording.folder_id = folder_id
|
||||
|
||||
# Apply auto-shares if moving to a group folder
|
||||
if folder.group_id and (folder.auto_share_on_apply or folder.share_with_group_lead):
|
||||
_apply_folder_auto_shares(recording, folder)
|
||||
|
||||
audit_access('move_folder', 'recording', recording_id, details={'folder_id': folder_id, 'old_folder_id': old_folder_id})
|
||||
db.session.commit() # commit folder + audit en une transaction atomique
|
||||
current_app.logger.info(f"Recording {recording_id} moved to folder {folder_id} by user {current_user.id}")
|
||||
else:
|
||||
# Remove from folder
|
||||
recording.folder_id = None
|
||||
db.session.commit()
|
||||
current_app.logger.info(f"Recording {recording_id} removed from folder by user {current_user.id}")
|
||||
|
||||
return jsonify(recording.to_dict(include_html=False, viewer_user=current_user))
|
||||
|
||||
|
||||
@folders_bp.route('/api/recordings/<int:recording_id>/folder', methods=['DELETE'])
|
||||
@login_required
|
||||
def remove_recording_folder(recording_id):
|
||||
"""Remove a recording from its folder."""
|
||||
# Check if folders feature is enabled
|
||||
folders_enabled = SystemSetting.get_setting('enable_folders', False)
|
||||
if not folders_enabled:
|
||||
return jsonify({'error': 'Folders feature is not enabled'}), 403
|
||||
|
||||
recording = db.session.get(Recording, recording_id)
|
||||
if not recording:
|
||||
return jsonify({'error': 'Recording not found'}), 404
|
||||
|
||||
# Check access to recording (require edit permission)
|
||||
if has_recording_access:
|
||||
if not has_recording_access(recording, current_user, require_edit=True):
|
||||
return jsonify({'error': 'You do not have permission to modify this recording'}), 403
|
||||
else:
|
||||
# Fallback: only owner can remove folder
|
||||
if recording.user_id != current_user.id:
|
||||
return jsonify({'error': 'You do not have permission to modify this recording'}), 403
|
||||
|
||||
recording.folder_id = None
|
||||
db.session.commit()
|
||||
current_app.logger.info(f"Recording {recording_id} removed from folder by user {current_user.id}")
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@folders_bp.route('/api/recordings/bulk/folder', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_assign_folder():
|
||||
"""Assign multiple recordings to a folder."""
|
||||
# Check if folders feature is enabled
|
||||
folders_enabled = SystemSetting.get_setting('enable_folders', False)
|
||||
if not folders_enabled:
|
||||
return jsonify({'error': 'Folders feature is not enabled'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
recording_ids = data.get('recording_ids', [])
|
||||
folder_id = data.get('folder_id') # Can be None to remove from folder
|
||||
|
||||
if not recording_ids:
|
||||
return jsonify({'error': 'No recordings specified'}), 400
|
||||
|
||||
# Verify folder if specified
|
||||
folder = None
|
||||
if folder_id:
|
||||
folder = db.session.get(Folder, folder_id)
|
||||
if not folder:
|
||||
return jsonify({'error': 'Folder not found'}), 404
|
||||
|
||||
# Check if user can use this folder
|
||||
if folder.group_id:
|
||||
membership = GroupMembership.query.filter_by(
|
||||
group_id=folder.group_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
if not membership:
|
||||
return jsonify({'error': 'You do not have access to this folder'}), 403
|
||||
else:
|
||||
if folder.user_id != current_user.id:
|
||||
return jsonify({'error': 'You do not have access to this folder'}), 403
|
||||
|
||||
updated_count = 0
|
||||
for rec_id in recording_ids:
|
||||
recording = db.session.get(Recording, rec_id)
|
||||
if not recording:
|
||||
continue
|
||||
|
||||
# Check access (require edit permission)
|
||||
if has_recording_access:
|
||||
if not has_recording_access(recording, current_user, require_edit=True):
|
||||
continue
|
||||
else:
|
||||
if recording.user_id != current_user.id:
|
||||
continue
|
||||
|
||||
recording.folder_id = folder_id
|
||||
|
||||
# Apply auto-shares if moving to a group folder
|
||||
if folder and folder.group_id and (folder.auto_share_on_apply or folder.share_with_group_lead):
|
||||
_apply_folder_auto_shares(recording, folder)
|
||||
|
||||
updated_count += 1
|
||||
|
||||
db.session.commit()
|
||||
action = f"moved to folder {folder_id}" if folder_id else "removed from folder"
|
||||
current_app.logger.info(f"Bulk folder update: {updated_count} recordings {action} by user {current_user.id}")
|
||||
|
||||
return jsonify({'success': True, 'updated_count': updated_count})
|
||||
|
||||
|
||||
def _apply_folder_auto_shares(recording, folder):
|
||||
"""
|
||||
Apply auto-shares for a group folder when a recording is assigned to it.
|
||||
|
||||
Args:
|
||||
recording: Recording being assigned to the folder
|
||||
folder: Folder with auto-share settings
|
||||
"""
|
||||
if not ENABLE_INTERNAL_SHARING:
|
||||
return
|
||||
|
||||
if not folder.group_id:
|
||||
return
|
||||
|
||||
# Determine who to share with
|
||||
if folder.auto_share_on_apply:
|
||||
group_members = GroupMembership.query.filter_by(group_id=folder.group_id).all()
|
||||
elif folder.share_with_group_lead:
|
||||
group_members = GroupMembership.query.filter_by(group_id=folder.group_id, role='admin').all()
|
||||
else:
|
||||
return
|
||||
|
||||
shares_created = 0
|
||||
|
||||
for membership in group_members:
|
||||
# Skip the recording owner
|
||||
if membership.user_id == recording.user_id:
|
||||
continue
|
||||
|
||||
# Check if already shared
|
||||
existing_share = InternalShare.query.filter_by(
|
||||
recording_id=recording.id,
|
||||
shared_with_user_id=membership.user_id
|
||||
).first()
|
||||
|
||||
if not existing_share:
|
||||
# Create internal share with correct permissions
|
||||
share = InternalShare(
|
||||
recording_id=recording.id,
|
||||
owner_id=recording.user_id,
|
||||
shared_with_user_id=membership.user_id,
|
||||
can_edit=(membership.role == 'admin'),
|
||||
can_reshare=False,
|
||||
source_type='group_folder',
|
||||
source_tag_id=None # We don't use this field for folders
|
||||
)
|
||||
db.session.add(share)
|
||||
|
||||
# Create SharedRecordingState with default values for the recipient
|
||||
state = SharedRecordingState(
|
||||
recording_id=recording.id,
|
||||
user_id=membership.user_id,
|
||||
is_inbox=True,
|
||||
is_highlighted=False
|
||||
)
|
||||
db.session.add(state)
|
||||
|
||||
shares_created += 1
|
||||
current_app.logger.info(f"Auto-shared recording {recording.id} with user {membership.user_id} via group folder '{folder.name}'")
|
||||
|
||||
if shares_created > 0:
|
||||
current_app.logger.info(f"Created {shares_created} auto-shares for recording {recording.id} via folder assignment")
|
||||
394
src/api/groups.py
Normal file
394
src/api/groups.py
Normal file
@@ -0,0 +1,394 @@
|
||||
"""
|
||||
Group management and collaboration.
|
||||
|
||||
This blueprint was auto-generated from app.py route extraction.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, Response, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from src.database import db
|
||||
from src.models import *
|
||||
from src.utils import *
|
||||
|
||||
# Create blueprint
|
||||
groups_bp = Blueprint('groups', __name__)
|
||||
|
||||
# Configuration from environment
|
||||
ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true'
|
||||
ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true'
|
||||
USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true'
|
||||
ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true'
|
||||
USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true'
|
||||
|
||||
# Global helpers (will be injected from app)
|
||||
has_recording_access = None
|
||||
bcrypt = None
|
||||
csrf = None
|
||||
limiter = None
|
||||
|
||||
def init_groups_helpers(**kwargs):
|
||||
"""Initialize helper functions and extensions from app."""
|
||||
global has_recording_access, bcrypt, csrf, limiter
|
||||
has_recording_access = kwargs.get('has_recording_access')
|
||||
bcrypt = kwargs.get('bcrypt')
|
||||
csrf = kwargs.get('csrf')
|
||||
limiter = kwargs.get('limiter')
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@groups_bp.route('/api/groups/<int:group_id>/sync-shares', methods=['POST'])
|
||||
@login_required
|
||||
def sync_team_tag_shares(group_id):
|
||||
"""Retroactively share recordings with group members based on group tags with auto-sharing enabled."""
|
||||
# Verify group exists
|
||||
group = db.session.get(Group, group_id)
|
||||
if not group:
|
||||
return jsonify({'error': 'Group not found'}), 404
|
||||
|
||||
# Verify user is a group admin
|
||||
membership = GroupMembership.query.filter_by(
|
||||
group_id=group_id,
|
||||
user_id=current_user.id,
|
||||
role='admin'
|
||||
).first()
|
||||
|
||||
if not membership:
|
||||
return jsonify({'error': 'Only group admins can sync shares'}), 403
|
||||
|
||||
if not ENABLE_INTERNAL_SHARING:
|
||||
return jsonify({'error': 'Internal sharing is not enabled'}), 403
|
||||
|
||||
# Get all group tags with auto-sharing enabled
|
||||
group_tags = Tag.query.filter(
|
||||
Tag.group_id == group_id,
|
||||
db.or_(
|
||||
Tag.auto_share_on_apply == True,
|
||||
Tag.share_with_group_lead == True
|
||||
)
|
||||
).all()
|
||||
|
||||
shares_created = 0
|
||||
recordings_processed = 0
|
||||
|
||||
for tag in group_tags:
|
||||
# Get all completed recordings with this tag
|
||||
recordings = db.session.query(Recording).join(RecordingTag).filter(
|
||||
RecordingTag.tag_id == tag.id,
|
||||
Recording.status == 'COMPLETED'
|
||||
).all()
|
||||
|
||||
for recording in recordings:
|
||||
recordings_processed += 1
|
||||
|
||||
# Determine who to share with
|
||||
if tag.auto_share_on_apply:
|
||||
group_members = GroupMembership.query.filter_by(group_id=group_id).all()
|
||||
elif tag.share_with_group_lead:
|
||||
group_members = GroupMembership.query.filter_by(group_id=group_id, role='admin').all()
|
||||
else:
|
||||
continue
|
||||
|
||||
for membership_to_share in group_members:
|
||||
# Skip the recording owner
|
||||
if membership_to_share.user_id == recording.user_id:
|
||||
continue
|
||||
|
||||
# Check if already shared
|
||||
existing_share = InternalShare.query.filter_by(
|
||||
recording_id=recording.id,
|
||||
shared_with_user_id=membership_to_share.user_id
|
||||
).first()
|
||||
|
||||
if not existing_share:
|
||||
# Create internal share with correct permissions
|
||||
# Group admins get edit permission, regular members get read-only
|
||||
share = InternalShare(
|
||||
recording_id=recording.id,
|
||||
owner_id=recording.user_id,
|
||||
shared_with_user_id=membership_to_share.user_id,
|
||||
can_edit=(membership_to_share.role == 'admin'),
|
||||
can_reshare=False,
|
||||
source_type='group_tag',
|
||||
source_tag_id=tag.id
|
||||
)
|
||||
db.session.add(share)
|
||||
|
||||
# Create SharedRecordingState with default values for the recipient
|
||||
state = SharedRecordingState(
|
||||
recording_id=recording.id,
|
||||
user_id=membership_to_share.user_id,
|
||||
is_inbox=True, # New shares appear in inbox by default
|
||||
is_highlighted=False # Not favorited by default
|
||||
)
|
||||
db.session.add(state)
|
||||
|
||||
shares_created += 1
|
||||
current_app.logger.info(f"Synced share: Recording {recording.id} with user {membership_to_share.user_id} (role={membership_to_share.role}) via group tag '{tag.name}'")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'shares_created': shares_created,
|
||||
'recordings_processed': recordings_processed,
|
||||
'message': f'Created {shares_created} new shares across {recordings_processed} recordings'
|
||||
})
|
||||
|
||||
|
||||
|
||||
@groups_bp.route('/api/admin/groups', methods=['GET'])
|
||||
@login_required
|
||||
def get_teams():
|
||||
"""Get all groups (admin) or groups user is admin of (group admin)."""
|
||||
# Check if user is admin OR group admin
|
||||
is_group_admin = GroupMembership.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
role='admin'
|
||||
).first() is not None
|
||||
|
||||
if not current_user.is_admin and not is_group_admin:
|
||||
return jsonify({'error': 'Admin access required'}), 403
|
||||
|
||||
# If full admin, return all groups; if group admin, return only their groups
|
||||
if current_user.is_admin:
|
||||
groups = Group.query.all()
|
||||
else:
|
||||
# Get groups where user is an admin
|
||||
group_memberships = GroupMembership.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
role='admin'
|
||||
).all()
|
||||
groups = [m.group for m in group_memberships]
|
||||
|
||||
return jsonify({'groups': [group.to_dict() for group in groups]})
|
||||
|
||||
|
||||
|
||||
@groups_bp.route('/api/admin/groups', methods=['POST'])
|
||||
@login_required
|
||||
def create_team():
|
||||
"""Create a new group (admin only)."""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'error': 'Admin access required'}), 403
|
||||
|
||||
if not ENABLE_INTERNAL_SHARING:
|
||||
return jsonify({'error': 'Groups require internal sharing to be enabled. Please set ENABLE_INTERNAL_SHARING=true in your configuration.'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
description = data.get('description', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': 'Group name is required'}), 400
|
||||
|
||||
# Check if group name already exists
|
||||
existing = Group.query.filter_by(name=name).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'A group with this name already exists'}), 400
|
||||
|
||||
group = Group(name=name, description=description)
|
||||
db.session.add(group)
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f"Admin {current_user.username} created group: {name}")
|
||||
return jsonify(group.to_dict()), 201
|
||||
|
||||
|
||||
|
||||
@groups_bp.route('/api/admin/groups/<int:group_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_team(group_id):
|
||||
"""Get group details (admin or group admin)."""
|
||||
group = db.session.get(Group, group_id)
|
||||
if not group:
|
||||
return jsonify({'error': 'Group not found'}), 404
|
||||
|
||||
# Check if user is admin OR admin of this specific group
|
||||
is_group_admin = GroupMembership.query.filter_by(
|
||||
group_id=group_id,
|
||||
user_id=current_user.id,
|
||||
role='admin'
|
||||
).first() is not None
|
||||
|
||||
if not current_user.is_admin and not is_group_admin:
|
||||
return jsonify({'error': 'Admin access required'}), 403
|
||||
|
||||
group_dict = group.to_dict()
|
||||
group_dict['members'] = [m.to_dict() for m in group.memberships]
|
||||
return jsonify(group_dict)
|
||||
|
||||
|
||||
|
||||
@groups_bp.route('/api/admin/groups/<int:group_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_team(group_id):
|
||||
"""Update group (admin or group admin)."""
|
||||
group = db.session.get(Group, group_id)
|
||||
if not group:
|
||||
return jsonify({'error': 'Group not found'}), 404
|
||||
|
||||
# Check if user is admin OR admin of this specific group
|
||||
is_group_admin = GroupMembership.query.filter_by(
|
||||
group_id=group_id,
|
||||
user_id=current_user.id,
|
||||
role='admin'
|
||||
).first() is not None
|
||||
|
||||
if not current_user.is_admin and not is_group_admin:
|
||||
return jsonify({'error': 'Admin access required'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
description = data.get('description', '').strip()
|
||||
|
||||
if name:
|
||||
# Check if new name conflicts with another group
|
||||
existing = Group.query.filter(Group.name == name, Group.id != group_id).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'A group with this name already exists'}), 400
|
||||
group.name = name
|
||||
|
||||
group.description = description
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f"Admin {current_user.username} updated group: {group.name}")
|
||||
return jsonify(group.to_dict())
|
||||
|
||||
|
||||
|
||||
@groups_bp.route('/api/admin/groups/<int:group_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_team(group_id):
|
||||
"""Delete group (admin only)."""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'error': 'Admin access required'}), 403
|
||||
|
||||
group = db.session.get(Group, group_id)
|
||||
if not group:
|
||||
return jsonify({'error': 'Group not found'}), 404
|
||||
|
||||
group_name = group.name
|
||||
db.session.delete(group)
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f"Admin {current_user.username} deleted group: {group_name}")
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
|
||||
@groups_bp.route('/api/admin/groups/<int:group_id>/members', methods=['POST'])
|
||||
@login_required
|
||||
def add_team_member(group_id):
|
||||
"""Add a member to a group (admin or group admin)."""
|
||||
if not ENABLE_INTERNAL_SHARING:
|
||||
return jsonify({'error': 'Groups require internal sharing to be enabled. Please set ENABLE_INTERNAL_SHARING=true in your configuration.'}), 403
|
||||
|
||||
group = db.session.get(Group, group_id)
|
||||
if not group:
|
||||
return jsonify({'error': 'Group not found'}), 404
|
||||
|
||||
# Check if user is admin OR admin of this specific group
|
||||
is_group_admin = GroupMembership.query.filter_by(
|
||||
group_id=group_id,
|
||||
user_id=current_user.id,
|
||||
role='admin'
|
||||
).first() is not None
|
||||
|
||||
if not current_user.is_admin and not is_group_admin:
|
||||
return jsonify({'error': 'Admin access required'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
user_id = data.get('user_id')
|
||||
role = data.get('role', 'member')
|
||||
|
||||
if not user_id:
|
||||
return jsonify({'error': 'User ID is required'}), 400
|
||||
|
||||
if role not in ['admin', 'member']:
|
||||
return jsonify({'error': 'Role must be "admin" or "member"'}), 400
|
||||
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
return jsonify({'error': 'User not found'}), 404
|
||||
|
||||
# Check if already a member
|
||||
existing = GroupMembership.query.filter_by(group_id=group_id, user_id=user_id).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'User is already a member of this group'}), 400
|
||||
|
||||
membership = GroupMembership(group_id=group_id, user_id=user_id, role=role)
|
||||
db.session.add(membership)
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f"Admin {current_user.username} added {user.username} to group {group.name} as {role}")
|
||||
return jsonify(membership.to_dict()), 201
|
||||
|
||||
|
||||
|
||||
@groups_bp.route('/api/admin/groups/<int:group_id>/members/<int:user_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_team_member(group_id, user_id):
|
||||
"""Update group member role (admin or group admin)."""
|
||||
membership = GroupMembership.query.filter_by(group_id=group_id, user_id=user_id).first()
|
||||
if not membership:
|
||||
return jsonify({'error': 'Membership not found'}), 404
|
||||
|
||||
# Check if user is admin OR admin of this specific group
|
||||
is_group_admin = GroupMembership.query.filter_by(
|
||||
group_id=group_id,
|
||||
user_id=current_user.id,
|
||||
role='admin'
|
||||
).first() is not None
|
||||
|
||||
if not current_user.is_admin and not is_group_admin:
|
||||
return jsonify({'error': 'Admin access required'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
role = data.get('role')
|
||||
|
||||
if role not in ['admin', 'member']:
|
||||
return jsonify({'error': 'Role must be "admin" or "member"'}), 400
|
||||
|
||||
membership.role = role
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f"Admin {current_user.username} updated {membership.user.username} role to {role} in group {membership.group.name}")
|
||||
return jsonify(membership.to_dict())
|
||||
|
||||
|
||||
|
||||
@groups_bp.route('/api/admin/groups/<int:group_id>/members/<int:user_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def remove_team_member(group_id, user_id):
|
||||
"""Remove a member from a group (admin or group admin)."""
|
||||
membership = GroupMembership.query.filter_by(group_id=group_id, user_id=user_id).first()
|
||||
if not membership:
|
||||
return jsonify({'error': 'Membership not found'}), 404
|
||||
|
||||
# Check if user is admin OR admin of this specific group
|
||||
is_group_admin = GroupMembership.query.filter_by(
|
||||
group_id=group_id,
|
||||
user_id=current_user.id,
|
||||
role='admin'
|
||||
).first() is not None
|
||||
|
||||
if not current_user.is_admin and not is_group_admin:
|
||||
return jsonify({'error': 'Admin access required'}), 403
|
||||
|
||||
username = membership.user.username
|
||||
group_name = membership.group.name
|
||||
db.session.delete(membership)
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f"Admin {current_user.username} removed {username} from group {group_name}")
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
|
||||
859
src/api/inquire.py
Normal file
859
src/api/inquire.py
Normal file
@@ -0,0 +1,859 @@
|
||||
"""
|
||||
Semantic search and chat functionality.
|
||||
|
||||
This blueprint was auto-generated from app.py route extraction.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, Response, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from src.database import db
|
||||
from src.models import *
|
||||
from src.utils import *
|
||||
from src.services.embeddings import get_accessible_recording_ids, semantic_search_chunks, EMBEDDINGS_AVAILABLE
|
||||
from src.services.llm import call_llm_completion, call_chat_completion, process_streaming_with_thinking, client, chat_client, TokenBudgetExceeded
|
||||
|
||||
# Create blueprint
|
||||
inquire_bp = Blueprint('inquire', __name__)
|
||||
|
||||
# Configuration from environment
|
||||
ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true'
|
||||
ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true'
|
||||
USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true'
|
||||
ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true'
|
||||
USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true'
|
||||
|
||||
# Global helpers (will be injected from app)
|
||||
has_recording_access = None
|
||||
bcrypt = None
|
||||
csrf = None
|
||||
limiter = None
|
||||
|
||||
def init_inquire_helpers(**kwargs):
|
||||
"""Initialize helper functions and extensions from app."""
|
||||
global has_recording_access, bcrypt, csrf, limiter
|
||||
has_recording_access = kwargs.get('has_recording_access')
|
||||
bcrypt = kwargs.get('bcrypt')
|
||||
csrf = kwargs.get('csrf')
|
||||
limiter = kwargs.get('limiter')
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@inquire_bp.route('/inquire')
|
||||
@login_required
|
||||
def inquire():
|
||||
# Check if inquire mode is enabled
|
||||
if not ENABLE_INQUIRE_MODE:
|
||||
flash('Inquire mode is not enabled on this server.', 'warning')
|
||||
return redirect(url_for('recordings.index'))
|
||||
|
||||
# Check if user is a group admin
|
||||
is_team_admin = GroupMembership.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
role='admin'
|
||||
).first() is not None
|
||||
|
||||
# Render the inquire page with user context for theming
|
||||
return render_template('inquire.html',
|
||||
use_asr_endpoint=USE_ASR_ENDPOINT,
|
||||
current_user=current_user,
|
||||
is_team_admin=is_team_admin)
|
||||
|
||||
|
||||
|
||||
@inquire_bp.route('/api/inquire/sessions', methods=['GET'])
|
||||
@login_required
|
||||
def get_inquire_sessions():
|
||||
"""Get all inquire sessions for the current user."""
|
||||
if not ENABLE_INQUIRE_MODE:
|
||||
return jsonify({'error': 'Inquire mode is not enabled'}), 403
|
||||
try:
|
||||
sessions = InquireSession.query.filter_by(user_id=current_user.id).order_by(InquireSession.last_used.desc()).all()
|
||||
return jsonify([session.to_dict() for session in sessions])
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting inquire sessions: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
|
||||
@inquire_bp.route('/api/inquire/sessions', methods=['POST'])
|
||||
@login_required
|
||||
def create_inquire_session():
|
||||
"""Create a new inquire session with filters."""
|
||||
if not ENABLE_INQUIRE_MODE:
|
||||
return jsonify({'error': 'Inquire mode is not enabled'}), 403
|
||||
try:
|
||||
data = request.json
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
session = InquireSession(
|
||||
user_id=current_user.id,
|
||||
session_name=data.get('session_name'),
|
||||
filter_tags=json.dumps(data.get('filter_tags', [])),
|
||||
filter_speakers=json.dumps(data.get('filter_speakers', [])),
|
||||
filter_date_from=datetime.fromisoformat(data['filter_date_from']).date() if data.get('filter_date_from') else None,
|
||||
filter_date_to=datetime.fromisoformat(data['filter_date_to']).date() if data.get('filter_date_to') else None,
|
||||
filter_recording_ids=json.dumps(data.get('filter_recording_ids', []))
|
||||
)
|
||||
|
||||
db.session.add(session)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(session.to_dict()), 201
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error creating inquire session: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
|
||||
@inquire_bp.route('/api/inquire/search', methods=['POST'])
|
||||
@login_required
|
||||
def inquire_search():
|
||||
"""Perform semantic search within filtered transcriptions."""
|
||||
if not ENABLE_INQUIRE_MODE:
|
||||
return jsonify({'error': 'Inquire mode is not enabled'}), 403
|
||||
try:
|
||||
data = request.json
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
query = data.get('query')
|
||||
if not query:
|
||||
return jsonify({'error': 'No query provided'}), 400
|
||||
|
||||
# Build filters from request
|
||||
filters = {}
|
||||
if data.get('filter_tags'):
|
||||
filters['tag_ids'] = data['filter_tags']
|
||||
if data.get('filter_speakers'):
|
||||
filters['speaker_names'] = data['filter_speakers']
|
||||
if data.get('filter_recording_ids'):
|
||||
filters['recording_ids'] = data['filter_recording_ids']
|
||||
if data.get('filter_date_from'):
|
||||
filters['date_from'] = datetime.fromisoformat(data['filter_date_from']).date()
|
||||
if data.get('filter_date_to'):
|
||||
filters['date_to'] = datetime.fromisoformat(data['filter_date_to']).date()
|
||||
|
||||
# Perform semantic search
|
||||
top_k = data.get('top_k', 5)
|
||||
chunk_results = semantic_search_chunks(current_user.id, query, filters, top_k)
|
||||
|
||||
# Format results
|
||||
results = []
|
||||
for chunk, similarity in chunk_results:
|
||||
result = chunk.to_dict()
|
||||
result['similarity'] = similarity
|
||||
result['recording_title'] = chunk.recording.title
|
||||
result['recording_meeting_date'] = local_datetime_filter(chunk.recording.meeting_date)
|
||||
results.append(result)
|
||||
|
||||
return jsonify({'results': results})
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error in inquire search: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
|
||||
@inquire_bp.route('/api/inquire/chat', methods=['POST'])
|
||||
@login_required
|
||||
def inquire_chat():
|
||||
"""Chat with filtered transcriptions using RAG."""
|
||||
if not ENABLE_INQUIRE_MODE:
|
||||
return jsonify({'error': 'Inquire mode is not enabled'}), 403
|
||||
try:
|
||||
data = request.json
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
user_message = data.get('message')
|
||||
message_history = data.get('message_history', [])
|
||||
|
||||
if not user_message:
|
||||
return jsonify({'error': 'No message provided'}), 400
|
||||
|
||||
# Check if OpenRouter client is available
|
||||
if client is None:
|
||||
return jsonify({'error': 'Chat service is not available (OpenRouter client not configured)'}), 503
|
||||
|
||||
# Build filters from request
|
||||
filters = {}
|
||||
if data.get('filter_tags'):
|
||||
filters['tag_ids'] = data['filter_tags']
|
||||
if data.get('filter_speakers'):
|
||||
filters['speaker_names'] = data['filter_speakers']
|
||||
if data.get('filter_recording_ids'):
|
||||
filters['recording_ids'] = data['filter_recording_ids']
|
||||
if data.get('filter_date_from'):
|
||||
filters['date_from'] = datetime.fromisoformat(data['filter_date_from']).date()
|
||||
if data.get('filter_date_to'):
|
||||
filters['date_to'] = datetime.fromisoformat(data['filter_date_to']).date()
|
||||
|
||||
# Debug logging
|
||||
current_app.logger.info(f"Inquire chat - User: {current_user.username}, Query: '{user_message}', Filters: {filters}")
|
||||
|
||||
# Capture user context and app before generator to avoid context issues
|
||||
user_id = current_user.id
|
||||
user_name = current_user.name if current_user.name else "the user"
|
||||
user_title = current_user.job_title if current_user.job_title else "professional"
|
||||
user_company = current_user.company if current_user.company else "their organization"
|
||||
user_output_language = current_user.output_language if current_user.output_language else None
|
||||
app = current_app._get_current_object() # Capture app for use in generator
|
||||
|
||||
# Enhanced query processing with enrichment and debugging
|
||||
def create_status_response(status, message):
|
||||
"""Helper to create SSE status updates"""
|
||||
return f"data: {json.dumps({'status': status, 'message': message})}\n\n"
|
||||
|
||||
def generate_enhanced_chat():
|
||||
# Explicitly reference outer scope variables
|
||||
nonlocal user_id, user_name, user_title, user_company, user_output_language, data, filters
|
||||
|
||||
# Push app context for entire generator execution
|
||||
# This is needed because call_llm_completion uses current_app.logger internally
|
||||
ctx = app.app_context()
|
||||
ctx.push()
|
||||
|
||||
try:
|
||||
# Send initial status
|
||||
yield create_status_response('processing', 'Analyzing your query...')
|
||||
|
||||
# Step 1: Router - Determine if RAG lookup is needed
|
||||
router_prompt = f"""Analyse cette requête pour déterminer si elle nécessite une recherche dans les transcriptions ou si c'est une demande de reformatage/clarification.
|
||||
|
||||
Requête : "{user_message}"
|
||||
|
||||
Réponds UNIQUEMENT avec "RAG" si la requête demande du contenu des transcriptions (informations, conversations, faits précis).
|
||||
Réponds UNIQUEMENT avec "DIRECT" si c'est une demande de mise en forme, reformulation de la réponse précédente, ou sans besoin de recherche.
|
||||
|
||||
Exemples :
|
||||
- "Qu'est-ce que Marie a dit sur le budget ?" → RAG
|
||||
- "Peux-tu reformater en titres séparés ?" → DIRECT
|
||||
- "Qui a mentionné l'échéancier ?" → RAG
|
||||
- "Rends ça plus structuré" → DIRECT"""
|
||||
|
||||
try:
|
||||
router_response = call_llm_completion(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a query router. Respond with only 'RAG' or 'DIRECT'."},
|
||||
{"role": "user", "content": router_prompt}
|
||||
],
|
||||
temperature=0.1,
|
||||
max_tokens=10,
|
||||
user_id=user_id,
|
||||
operation_type='query_routing'
|
||||
)
|
||||
|
||||
raw_decision = router_response.choices[0].message.content
|
||||
if not raw_decision or not raw_decision.strip():
|
||||
app.logger.warning("Router returned empty response, defaulting to RAG")
|
||||
raise ValueError("Empty router response")
|
||||
|
||||
route_decision = raw_decision.strip().upper()
|
||||
app.logger.info(f"Router decision: {route_decision}")
|
||||
|
||||
if route_decision == "DIRECT":
|
||||
# Direct response without RAG lookup
|
||||
yield create_status_response('responding', 'Generating direct response...')
|
||||
|
||||
direct_prompt = f"""Tu assistes {user_name}. Réponds directement à sa demande de façon professionnelle et concise. Utilise le formatage Markdown (## titres, **gras**, listes -).
|
||||
|
||||
Demande : "{user_message}"
|
||||
|
||||
Contexte de conversation (si pertinent) :
|
||||
{json.dumps(message_history[-2:] if message_history else [])}"""
|
||||
|
||||
stream = call_llm_completion(
|
||||
messages=[
|
||||
{"role": "system", "content": direct_prompt},
|
||||
{"role": "user", "content": user_message}
|
||||
],
|
||||
temperature=0.7,
|
||||
max_tokens=int(os.environ.get("CHAT_MAX_TOKENS", "2000")),
|
||||
stream=True,
|
||||
user_id=user_id,
|
||||
operation_type='chat'
|
||||
)
|
||||
|
||||
# Use helper function to process streaming with thinking tag support
|
||||
for response in process_streaming_with_thinking(stream, user_id=user_id, operation_type='chat', model_name=os.environ.get('LLM_MODEL')):
|
||||
yield response
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
app.logger.warning(f"Router failed, defaulting to RAG: {e}")
|
||||
|
||||
# Step 2: Query enrichment - generate better search terms based on user intent
|
||||
yield create_status_response('enriching', 'Enriching search query...')
|
||||
|
||||
# Use captured user context for personalized search terms
|
||||
|
||||
if EMBEDDINGS_AVAILABLE:
|
||||
enrichment_prompt = f"""Tu es un assistant spécialisé en recherche sémantique. Génère 3-5 termes ou phrases de recherche alternatifs pour retrouver du contenu pertinent dans des transcriptions.
|
||||
|
||||
Contexte utilisateur :
|
||||
- Nom : {user_name}
|
||||
- Titre : {user_title}
|
||||
- Organisation : {user_company}
|
||||
|
||||
Question : "{user_message}"
|
||||
Intervenants disponibles : {', '.join(data.get('filter_speakers', []))}.
|
||||
|
||||
Génère des termes de recherche qui retrouveront le contenu pertinent. Priorités :
|
||||
1. Concepts clés et sujets — utilise le nom réel de l'utilisateur au lieu de "moi" ou "je"
|
||||
2. Terminologie spécifique au contexte professionnel
|
||||
3. Reformulations avec les noms propres
|
||||
4. Termes connexes susceptibles d'apparaître dans les transcriptions
|
||||
|
||||
Exemples :
|
||||
- Au lieu de "ce que Marie m'a dit" → "ce que Marie a dit à {user_name}"
|
||||
- Au lieu de "ma dernière réunion" → "réunion de {user_name}"
|
||||
|
||||
Réponds UNIQUEMENT avec un tableau JSON de chaînes : ["terme1", "terme2", "terme3", ...]"""
|
||||
else:
|
||||
enrichment_prompt = f"""Tu es un assistant spécialisé en extraction de mots-clés pour la recherche textuelle (SQL LIKE). Extrais 3-5 termes essentiels de la question.
|
||||
|
||||
Contexte utilisateur :
|
||||
- Nom : {user_name}
|
||||
|
||||
Question : "{user_message}"
|
||||
|
||||
Règles :
|
||||
- Retourne UNIQUEMENT les termes qui apparaîtraient réellement dans une transcription
|
||||
- Chaque terme : 1-3 mots maximum
|
||||
- Remplace les pronoms "moi", "mon", "je" par le nom de l'utilisateur "{user_name}"
|
||||
- Priorité aux noms propres, termes spécifiques, phrases distinctives
|
||||
- N'inclus PAS de mots génériques comme "réunion", "discussion", "plan" sauf s'ils sont le sujet
|
||||
|
||||
Exemples :
|
||||
- "qu'est-ce qui se passe avec le Régime de retraite" → ["Régime de retraite", "retraite"]
|
||||
- "quand Marie a-t-elle mentionné l'échéance" → ["Marie", "échéance", "délai"]
|
||||
|
||||
Réponds UNIQUEMENT avec un tableau JSON : ["terme1", "terme2", ...]"""
|
||||
|
||||
try:
|
||||
enrichment_response = call_llm_completion(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a query enhancement assistant. Respond only with valid JSON arrays of search terms."},
|
||||
{"role": "user", "content": enrichment_prompt}
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=200,
|
||||
user_id=user_id,
|
||||
operation_type='query_enrichment'
|
||||
)
|
||||
|
||||
raw_content = enrichment_response.choices[0].message.content
|
||||
if not raw_content or not raw_content.strip():
|
||||
app.logger.warning(f"Query enrichment returned empty response")
|
||||
raise ValueError("Empty response from LLM")
|
||||
|
||||
# Try to extract JSON array if wrapped in other text
|
||||
content = raw_content.strip()
|
||||
if content.startswith('['):
|
||||
enriched_terms = json.loads(content)
|
||||
else:
|
||||
# Try to find JSON array in the response
|
||||
match = re.search(r'\[.*?\]', content, re.DOTALL)
|
||||
if match:
|
||||
enriched_terms = json.loads(match.group())
|
||||
else:
|
||||
app.logger.warning(f"Query enrichment response not JSON: {content[:200]}")
|
||||
raise ValueError("No JSON array found in response")
|
||||
|
||||
app.logger.info(f"Enriched search terms: {enriched_terms}")
|
||||
|
||||
# Combine original query with enriched terms for search
|
||||
search_queries = [user_message] + enriched_terms[:3] # Use original + top 3 enriched terms
|
||||
|
||||
except Exception as e:
|
||||
app.logger.warning(f"Query enrichment failed, using original query: {e}")
|
||||
search_queries = [user_message]
|
||||
|
||||
# Step 2: Semantic search with multiple queries
|
||||
yield create_status_response('searching', 'Searching transcriptions...')
|
||||
|
||||
all_chunks = []
|
||||
seen_chunk_ids = set()
|
||||
|
||||
for query in search_queries:
|
||||
with app.app_context():
|
||||
chunk_results = semantic_search_chunks(user_id, query, filters, 8)
|
||||
app.logger.info(f"Search query '{query}' returned {len(chunk_results)} chunks")
|
||||
|
||||
for chunk, similarity in chunk_results:
|
||||
if chunk and chunk.id not in seen_chunk_ids:
|
||||
all_chunks.append((chunk, similarity))
|
||||
seen_chunk_ids.add(chunk.id)
|
||||
|
||||
# Sort by similarity and take top results
|
||||
all_chunks.sort(key=lambda x: x[1], reverse=True)
|
||||
chunk_results = all_chunks[:data.get('context_chunks', 8)]
|
||||
|
||||
app.logger.info(f"Final chunk results: {len(chunk_results)} chunks with similarities: {[f'{s:.3f}' for _, s in chunk_results]}")
|
||||
|
||||
# Step 2.5: Auto-detect mentioned speakers and apply filters if needed
|
||||
with app.app_context():
|
||||
# Get available speakers
|
||||
recordings_with_participants = Recording.query.filter_by(user_id=user_id).filter(
|
||||
Recording.participants.isnot(None),
|
||||
Recording.participants != ''
|
||||
).all()
|
||||
|
||||
available_speakers = set()
|
||||
for recording in recordings_with_participants:
|
||||
if recording.participants:
|
||||
participants = [p.strip() for p in recording.participants.split(',') if p.strip()]
|
||||
available_speakers.update(participants)
|
||||
|
||||
# Check if any speakers are mentioned in the user query but missing from results
|
||||
mentioned_speakers = []
|
||||
for speaker in available_speakers:
|
||||
if speaker.lower() in user_message.lower():
|
||||
# Check if this speaker appears in current chunk results
|
||||
speaker_in_results = False
|
||||
for chunk, _ in chunk_results:
|
||||
if chunk and (
|
||||
(chunk.speaker_name and speaker.lower() in chunk.speaker_name.lower()) or
|
||||
(chunk.recording and chunk.recording.participants and speaker.lower() in chunk.recording.participants.lower())
|
||||
):
|
||||
speaker_in_results = True
|
||||
break
|
||||
|
||||
if not speaker_in_results:
|
||||
mentioned_speakers.append(speaker)
|
||||
|
||||
# If we found mentioned speakers not in results, automatically apply speaker filter
|
||||
if mentioned_speakers and not data.get('filter_speakers'): # Only if no speaker filter already applied
|
||||
app.logger.info(f"Auto-detected mentioned speakers not in results: {mentioned_speakers}")
|
||||
yield create_status_response('filtering', f'Detected mention of {", ".join(mentioned_speakers)}, applying speaker filter...')
|
||||
|
||||
# Apply automatic speaker filter
|
||||
auto_filters = filters.copy()
|
||||
auto_filters['speaker_names'] = mentioned_speakers
|
||||
|
||||
# Re-run semantic search with speaker filter
|
||||
auto_filtered_chunks = []
|
||||
auto_filtered_seen_ids = set()
|
||||
|
||||
for query in search_queries:
|
||||
with app.app_context():
|
||||
auto_filtered_results = semantic_search_chunks(user_id, query, auto_filters, data.get('context_chunks', 8))
|
||||
app.logger.info(f"Auto-filtered search for '{query}' with speakers {mentioned_speakers} returned {len(auto_filtered_results)} chunks")
|
||||
|
||||
for chunk, similarity in auto_filtered_results:
|
||||
if chunk and chunk.id not in auto_filtered_seen_ids:
|
||||
auto_filtered_chunks.append((chunk, similarity))
|
||||
auto_filtered_seen_ids.add(chunk.id)
|
||||
|
||||
# If auto-filter found better results, use them
|
||||
if len(auto_filtered_chunks) > 0:
|
||||
auto_filtered_chunks.sort(key=lambda x: x[1], reverse=True)
|
||||
chunk_results = auto_filtered_chunks[:data.get('context_chunks', 8)]
|
||||
app.logger.info(f"Auto speaker filter found {len(chunk_results)} relevant chunks, using filtered results")
|
||||
filters = auto_filters # Update filters for context building
|
||||
|
||||
# Step 3: Evaluate results and re-query if needed
|
||||
if len(chunk_results) < 2: # If we got very few results, try a broader search
|
||||
yield create_status_response('requerying', 'Expanding search scope...')
|
||||
|
||||
# Try without speaker filter if it was applied
|
||||
broader_filters = filters.copy()
|
||||
if 'speaker_names' in broader_filters:
|
||||
del broader_filters['speaker_names']
|
||||
app.logger.info("Retrying search without speaker filter...")
|
||||
|
||||
for query in search_queries:
|
||||
with app.app_context():
|
||||
chunk_results_broader = semantic_search_chunks(user_id, query, broader_filters, 6)
|
||||
for chunk, similarity in chunk_results_broader:
|
||||
if chunk and chunk.id not in seen_chunk_ids:
|
||||
all_chunks.append((chunk, similarity))
|
||||
seen_chunk_ids.add(chunk.id)
|
||||
|
||||
# Re-sort and limit
|
||||
all_chunks.sort(key=lambda x: x[1], reverse=True)
|
||||
chunk_results = all_chunks[:data.get('context_chunks', 8)]
|
||||
app.logger.info(f"Broader search returned {len(chunk_results)} total chunks")
|
||||
|
||||
# Build context from retrieved chunks
|
||||
yield create_status_response('contextualizing', 'Building context...')
|
||||
|
||||
# Group chunks by recording and organize properly
|
||||
recording_chunks = {}
|
||||
recording_ids_in_context = set()
|
||||
|
||||
for chunk, similarity in chunk_results:
|
||||
if not chunk or not chunk.recording:
|
||||
continue
|
||||
recording_id = chunk.recording.id
|
||||
recording_ids_in_context.add(recording_id)
|
||||
|
||||
if recording_id not in recording_chunks:
|
||||
recording_chunks[recording_id] = {
|
||||
'recording': chunk.recording,
|
||||
'chunks': []
|
||||
}
|
||||
|
||||
recording_chunks[recording_id]['chunks'].append({
|
||||
'chunk': chunk,
|
||||
'similarity': similarity
|
||||
})
|
||||
|
||||
# Build organized context pieces
|
||||
context_pieces = []
|
||||
|
||||
for recording_id, data in recording_chunks.items():
|
||||
recording = data['recording']
|
||||
chunks = data['chunks']
|
||||
|
||||
# Sort chunks by their index to maintain chronological order
|
||||
chunks.sort(key=lambda x: x['chunk'].chunk_index)
|
||||
|
||||
# Build recording header with complete metadata
|
||||
header = f"=== {recording.title} [Recording ID: {recording_id}] ==="
|
||||
if recording.meeting_date:
|
||||
header += f" ({recording.meeting_date})"
|
||||
|
||||
# Add participants information
|
||||
if recording.participants:
|
||||
participants_list = [p.strip() for p in recording.participants.split(',') if p.strip()]
|
||||
header += f"\\nParticipants: {', '.join(participants_list)}"
|
||||
|
||||
context_piece = header + "\\n\\n"
|
||||
|
||||
# Process chunks and detect non-continuity
|
||||
prev_chunk_index = None
|
||||
for chunk_data in chunks:
|
||||
chunk = chunk_data['chunk']
|
||||
similarity = chunk_data['similarity']
|
||||
|
||||
# Check for non-continuity
|
||||
if prev_chunk_index is not None and chunk.chunk_index != prev_chunk_index + 1:
|
||||
context_piece += "\\n[... gap in transcript - non-consecutive chunks ...]\\n\\n"
|
||||
|
||||
# Add speaker information if available
|
||||
speaker_info = ""
|
||||
if chunk.speaker_name:
|
||||
speaker_info = f"{chunk.speaker_name}: "
|
||||
elif chunk.start_time is not None:
|
||||
speaker_info = f"[{chunk.start_time:.1f}s]: "
|
||||
|
||||
# Add timing info if available
|
||||
timing_info = ""
|
||||
if chunk.start_time is not None and chunk.end_time is not None:
|
||||
timing_info = f" [{chunk.start_time:.1f}s-{chunk.end_time:.1f}s]"
|
||||
|
||||
context_piece += f"{speaker_info}{chunk.content}{timing_info} (similarity: {similarity:.3f})\\n\\n"
|
||||
prev_chunk_index = chunk.chunk_index
|
||||
|
||||
context_pieces.append(context_piece)
|
||||
|
||||
app.logger.info(f"Built context from {len(chunk_results)} chunks across {len(recording_chunks)} recordings")
|
||||
|
||||
# Generate response
|
||||
yield create_status_response('responding', 'Generating response...')
|
||||
|
||||
# Prepare system prompt
|
||||
language_instruction = f"Réponds en {user_output_language}." if user_output_language else "Réponds toujours en français."
|
||||
|
||||
# Build filter description for context
|
||||
filter_description = []
|
||||
with app.app_context():
|
||||
if data.get('filter_tags'):
|
||||
tag_names = [tag.name for tag in Tag.query.filter(Tag.id.in_(data['filter_tags'])).all()]
|
||||
filter_description.append(f"tags: {', '.join(tag_names)}")
|
||||
if data.get('filter_speakers'):
|
||||
filter_description.append(f"speakers: {', '.join(data['filter_speakers'])}")
|
||||
if data.get('filter_date_from') or data.get('filter_date_to'):
|
||||
date_range = []
|
||||
if data.get('filter_date_from'):
|
||||
date_range.append(f"from {data['filter_date_from']}")
|
||||
if data.get('filter_date_to'):
|
||||
date_range.append(f"to {data['filter_date_to']}")
|
||||
filter_description.append(f"dates: {' '.join(date_range)}")
|
||||
|
||||
filter_text = f" (filtered by {'; '.join(filter_description)})" if filter_description else ""
|
||||
|
||||
context_text = "\n\n".join(context_pieces) if context_pieces else "No relevant context found."
|
||||
|
||||
# Get transcript length limit setting and available speakers
|
||||
with app.app_context():
|
||||
transcript_limit = SystemSetting.get_setting('transcript_length_limit', 30000)
|
||||
|
||||
# Get all available speakers for this user
|
||||
recordings_with_participants = Recording.query.filter_by(user_id=user_id).filter(
|
||||
Recording.participants.isnot(None),
|
||||
Recording.participants != ''
|
||||
).all()
|
||||
|
||||
available_speakers = set()
|
||||
for recording in recordings_with_participants:
|
||||
if recording.participants:
|
||||
participants = [p.strip() for p in recording.participants.split(',') if p.strip()]
|
||||
available_speakers.update(participants)
|
||||
|
||||
available_speakers = sorted(list(available_speakers))
|
||||
|
||||
user_context = f", {user_title} chez {user_company}" if user_title and user_company else ""
|
||||
system_prompt = f"""Tu es un assistant expert en analyse de transcriptions audio et de réunions, qui assiste {user_name}{user_context}. {language_instruction}
|
||||
|
||||
Tu analyses des transcriptions de plusieurs enregistrements{filter_text}. Le contexte suivant a été récupéré par recherche sémantique :
|
||||
|
||||
<<début contexte>>
|
||||
{context_text}
|
||||
<<fin contexte>>
|
||||
|
||||
Recherche : {len(chunk_results)} extrait(s) de {len(recording_ids_in_context)} enregistrement(s).
|
||||
|
||||
**Intervenants disponibles** : {', '.join(available_speakers) if available_speakers else 'Non précisé'}
|
||||
|
||||
**IDs des enregistrements** : {list(recording_ids_in_context)}
|
||||
|
||||
CONSIGNES DE FORMATAGE :
|
||||
- Utilise le Markdown (## titres, **gras**, listes -)
|
||||
- Commence par une synthèse concise si pertinent
|
||||
- Organise par source avec le format : `## [Titre de l'enregistrement] - [Date]`
|
||||
- Indique les intervenants en **gras** pour les citations directes
|
||||
- Présente les enregistrements du plus récent au plus ancien
|
||||
|
||||
**Exemple de structure :**
|
||||
|
||||
## Réunion de planification — 2024-06-18
|
||||
- **Marie** a mentionné que "la mise en œuvre nécessite un soutien important"
|
||||
- **Jean** a confirmé la prochaine rencontre avec l'équipe technique
|
||||
- Points abordés :
|
||||
- Planification budgétaire
|
||||
- Coordination des échéanciers"""
|
||||
|
||||
# Prepare messages array
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
if message_history:
|
||||
messages.extend(message_history)
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
|
||||
# Enable streaming
|
||||
stream = call_chat_completion(
|
||||
messages=messages,
|
||||
temperature=0.7,
|
||||
max_tokens=int(os.environ.get("CHAT_MAX_TOKENS", "2000")),
|
||||
stream=True,
|
||||
user_id=user_id,
|
||||
operation_type='chat'
|
||||
)
|
||||
|
||||
# Buffer content to detect full transcript requests
|
||||
response_buffer = ""
|
||||
|
||||
# Buffer content to detect full transcript requests
|
||||
response_buffer = ""
|
||||
content_buffer = ""
|
||||
in_thinking = False
|
||||
thinking_buffer = ""
|
||||
|
||||
for chunk in stream:
|
||||
content = chunk.choices[0].delta.content
|
||||
if content:
|
||||
response_buffer += content
|
||||
content_buffer += content
|
||||
|
||||
# Check if this is a full transcript request
|
||||
if response_buffer.strip().startswith("REQUEST_FULL_TRANSCRIPT:"):
|
||||
lines = response_buffer.split('\n')
|
||||
request_line = lines[0].strip()
|
||||
|
||||
if ':' in request_line:
|
||||
try:
|
||||
recording_id = int(request_line.split(':')[1])
|
||||
app.logger.info(f"Agent requested full transcript for recording {recording_id}")
|
||||
|
||||
# Fetch full transcript
|
||||
yield create_status_response('fetching', f'Retrieving full transcript for recording {recording_id}...')
|
||||
|
||||
with app.app_context():
|
||||
recording = db.session.get(Recording, recording_id)
|
||||
if recording and recording.user_id == user_id and recording.transcription:
|
||||
# Apply transcript length limit
|
||||
if transcript_limit == -1:
|
||||
full_transcript = recording.transcription
|
||||
else:
|
||||
full_transcript = recording.transcription[:transcript_limit]
|
||||
|
||||
# Add full transcript to context
|
||||
full_context = f"{context_text}\n\n<<FULL TRANSCRIPT - {recording.title}>>\n{full_transcript}\n<<END FULL TRANSCRIPT>>"
|
||||
|
||||
# Update system prompt with full transcript
|
||||
updated_system_prompt = system_prompt.replace(
|
||||
f"<<start context>>\n{context_text}\n<<end context>>",
|
||||
f"<<start context>>\n{full_context}\n<<end context>>"
|
||||
)
|
||||
|
||||
# Create new messages with updated context
|
||||
updated_messages = [{"role": "system", "content": updated_system_prompt}]
|
||||
if message_history:
|
||||
updated_messages.extend(message_history)
|
||||
updated_messages.append({"role": "user", "content": user_message})
|
||||
|
||||
# Generate new response with full context
|
||||
yield create_status_response('responding', 'Analyzing full transcript...')
|
||||
|
||||
new_stream = call_chat_completion(
|
||||
messages=updated_messages,
|
||||
temperature=0.7,
|
||||
max_tokens=int(os.environ.get("CHAT_MAX_TOKENS", "2000")),
|
||||
stream=True,
|
||||
user_id=user_id,
|
||||
operation_type='chat'
|
||||
)
|
||||
|
||||
# Use helper function to process streaming with thinking tag support
|
||||
for response in process_streaming_with_thinking(new_stream, user_id=user_id, operation_type='chat', model_name=os.environ.get('CHAT_MODEL')):
|
||||
yield response
|
||||
return
|
||||
else:
|
||||
# Recording not found or no permission
|
||||
error_msg = f"\n\nError: Unable to access full transcript for recording {recording_id}. Recording may not exist or you may not have permission."
|
||||
yield f"data: {json.dumps({'delta': error_msg})}\n\n"
|
||||
yield f"data: {json.dumps({'end_of_stream': True})}\n\n"
|
||||
return
|
||||
|
||||
except (ValueError, IndexError):
|
||||
app.logger.warning(f"Invalid transcript request format: {request_line}")
|
||||
# Continue with normal streaming
|
||||
pass
|
||||
|
||||
# Process the buffer to detect and handle thinking tags
|
||||
while True:
|
||||
if not in_thinking:
|
||||
# Look for opening thinking tag
|
||||
think_start = re.search(r'<think(?:ing)?>', content_buffer, re.IGNORECASE)
|
||||
if think_start:
|
||||
# Send any content before the thinking tag
|
||||
before_thinking = content_buffer[:think_start.start()]
|
||||
if before_thinking:
|
||||
yield f"data: {json.dumps({'delta': before_thinking})}\n\n"
|
||||
|
||||
# Start capturing thinking content
|
||||
in_thinking = True
|
||||
content_buffer = content_buffer[think_start.end():]
|
||||
thinking_buffer = ""
|
||||
else:
|
||||
# No thinking tag found, send accumulated content
|
||||
if content_buffer:
|
||||
yield f"data: {json.dumps({'delta': content_buffer})}\n\n"
|
||||
content_buffer = ""
|
||||
break
|
||||
else:
|
||||
# We're inside a thinking tag, look for closing tag
|
||||
think_end = re.search(r'</think(?:ing)?>', content_buffer, re.IGNORECASE)
|
||||
if think_end:
|
||||
# Capture thinking content up to the closing tag
|
||||
thinking_buffer += content_buffer[:think_end.start()]
|
||||
|
||||
# Send the thinking content as a special type
|
||||
if thinking_buffer.strip():
|
||||
yield f"data: {json.dumps({'thinking': thinking_buffer.strip()})}\n\n"
|
||||
|
||||
# Continue processing after the closing tag
|
||||
in_thinking = False
|
||||
content_buffer = content_buffer[think_end.end():]
|
||||
thinking_buffer = ""
|
||||
else:
|
||||
# Still inside thinking tag, accumulate content
|
||||
thinking_buffer += content_buffer
|
||||
content_buffer = ""
|
||||
break
|
||||
|
||||
# Handle any remaining content
|
||||
if in_thinking and thinking_buffer:
|
||||
# Unclosed thinking tag - send as thinking content
|
||||
yield f"data: {json.dumps({'thinking': thinking_buffer.strip()})}\n\n"
|
||||
elif content_buffer:
|
||||
# Regular content
|
||||
yield f"data: {json.dumps({'delta': content_buffer})}\n\n"
|
||||
|
||||
yield f"data: {json.dumps({'end_of_stream': True})}\n\n"
|
||||
|
||||
except TokenBudgetExceeded as e:
|
||||
app.logger.warning(f"Token budget exceeded for user {user_id}: {e}")
|
||||
yield f"data: {json.dumps({'error': str(e), 'budget_exceeded': True})}\n\n"
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error in enhanced chat generation: {e}")
|
||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||
finally:
|
||||
ctx.pop()
|
||||
|
||||
return Response(generate_enhanced_chat(), mimetype='text/event-stream')
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error in inquire chat endpoint: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
|
||||
@inquire_bp.route('/api/inquire/available_filters', methods=['GET'])
|
||||
@login_required
|
||||
def get_available_filters():
|
||||
"""Get available filter options for the user (includes shared recordings)."""
|
||||
if not ENABLE_INQUIRE_MODE:
|
||||
return jsonify({'error': 'Inquire mode is not enabled'}), 403
|
||||
try:
|
||||
# Get user's personal tags
|
||||
user_tags = Tag.query.filter_by(user_id=current_user.id, group_id=None).all()
|
||||
|
||||
# Get group tags from user's teams
|
||||
group_tags = []
|
||||
memberships = GroupMembership.query.filter_by(user_id=current_user.id).all()
|
||||
group_ids = [m.group_id for m in memberships]
|
||||
if group_ids:
|
||||
group_tags = Tag.query.filter(Tag.group_id.in_(group_ids)).all()
|
||||
|
||||
# Combine all tags
|
||||
all_tags = user_tags + group_tags
|
||||
|
||||
# Get all accessible recording IDs (own + shared)
|
||||
accessible_recording_ids = get_accessible_recording_ids(current_user.id)
|
||||
|
||||
# Get unique speakers from accessible recordings' participants field
|
||||
recordings_with_participants = Recording.query.filter(
|
||||
Recording.id.in_(accessible_recording_ids),
|
||||
Recording.participants.isnot(None),
|
||||
Recording.participants != ''
|
||||
).all()
|
||||
|
||||
speaker_names = set()
|
||||
for recording in recordings_with_participants:
|
||||
if recording.participants:
|
||||
# Split participants by comma and clean up
|
||||
participants = [p.strip() for p in recording.participants.split(',') if p.strip()]
|
||||
speaker_names.update(participants)
|
||||
|
||||
speaker_names = sorted(list(speaker_names))
|
||||
|
||||
# Get accessible recordings for recording-specific filtering
|
||||
recordings = Recording.query.filter(
|
||||
Recording.id.in_(accessible_recording_ids),
|
||||
Recording.status == 'COMPLETED'
|
||||
).order_by(Recording.created_at.desc()).all()
|
||||
|
||||
return jsonify({
|
||||
'tags': [tag.to_dict() for tag in all_tags],
|
||||
'speakers': speaker_names,
|
||||
'recordings': [{'id': r.id, 'title': r.title, 'meeting_date': f"{r.meeting_date.isoformat()}T00:00:00" if r.meeting_date else None} for r in recordings]
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting available filters: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
|
||||
298
src/api/naming_templates.py
Normal file
298
src/api/naming_templates.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
Naming template management.
|
||||
|
||||
This blueprint handles CRUD operations for naming templates,
|
||||
which define how recording titles are generated from filenames,
|
||||
metadata, and AI-generated content.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from src.database import db
|
||||
from src.models import NamingTemplate
|
||||
|
||||
# Create blueprint
|
||||
naming_templates_bp = Blueprint('naming_templates', __name__)
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@naming_templates_bp.route('/api/naming-templates', methods=['GET'])
|
||||
@login_required
|
||||
def get_naming_templates():
|
||||
"""Get all naming templates for the current user."""
|
||||
templates = NamingTemplate.query.filter_by(user_id=current_user.id).all()
|
||||
return jsonify([template.to_dict() for template in templates])
|
||||
|
||||
|
||||
@naming_templates_bp.route('/api/naming-templates', methods=['POST'])
|
||||
@login_required
|
||||
def create_naming_template():
|
||||
"""Create a new naming template."""
|
||||
data = request.json
|
||||
if not data or not data.get('name') or not data.get('template'):
|
||||
return jsonify({'error': 'Name and template are required'}), 400
|
||||
|
||||
# Validate regex patterns if provided
|
||||
regex_patterns = data.get('regex_patterns', {})
|
||||
if regex_patterns:
|
||||
if not isinstance(regex_patterns, dict):
|
||||
return jsonify({'error': 'regex_patterns must be a dictionary'}), 400
|
||||
# Validate each regex pattern
|
||||
import re
|
||||
for var_name, pattern in regex_patterns.items():
|
||||
try:
|
||||
re.compile(pattern)
|
||||
except re.error as e:
|
||||
return jsonify({'error': f'Invalid regex pattern for "{var_name}": {str(e)}'}), 400
|
||||
|
||||
# If this is set as default, unset other defaults
|
||||
if data.get('is_default'):
|
||||
NamingTemplate.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
is_default=True
|
||||
).update({'is_default': False})
|
||||
|
||||
template = NamingTemplate(
|
||||
user_id=current_user.id,
|
||||
name=data['name'],
|
||||
template=data['template'],
|
||||
description=data.get('description'),
|
||||
regex_patterns=json.dumps(regex_patterns) if regex_patterns else None,
|
||||
is_default=data.get('is_default', False)
|
||||
)
|
||||
|
||||
db.session.add(template)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(template.to_dict()), 201
|
||||
|
||||
|
||||
@naming_templates_bp.route('/api/naming-templates/<int:template_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_naming_template(template_id):
|
||||
"""Get a specific naming template."""
|
||||
template = NamingTemplate.query.filter_by(
|
||||
id=template_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
return jsonify({'error': 'Template not found'}), 404
|
||||
|
||||
return jsonify(template.to_dict())
|
||||
|
||||
|
||||
@naming_templates_bp.route('/api/naming-templates/<int:template_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_naming_template(template_id):
|
||||
"""Update an existing naming template."""
|
||||
template = NamingTemplate.query.filter_by(
|
||||
id=template_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
return jsonify({'error': 'Template not found'}), 404
|
||||
|
||||
data = request.json
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
# Validate regex patterns if provided
|
||||
if 'regex_patterns' in data:
|
||||
regex_patterns = data['regex_patterns']
|
||||
if regex_patterns:
|
||||
if not isinstance(regex_patterns, dict):
|
||||
return jsonify({'error': 'regex_patterns must be a dictionary'}), 400
|
||||
import re
|
||||
for var_name, pattern in regex_patterns.items():
|
||||
try:
|
||||
re.compile(pattern)
|
||||
except re.error as e:
|
||||
return jsonify({'error': f'Invalid regex pattern for "{var_name}": {str(e)}'}), 400
|
||||
|
||||
# If this is set as default, unset other defaults
|
||||
if data.get('is_default'):
|
||||
NamingTemplate.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
is_default=True
|
||||
).update({'is_default': False})
|
||||
|
||||
template.name = data.get('name', template.name)
|
||||
template.template = data.get('template', template.template)
|
||||
template.description = data.get('description', template.description)
|
||||
template.is_default = data.get('is_default', template.is_default)
|
||||
|
||||
if 'regex_patterns' in data:
|
||||
regex_patterns = data['regex_patterns']
|
||||
template.regex_patterns = json.dumps(regex_patterns) if regex_patterns else None
|
||||
|
||||
template.updated_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(template.to_dict())
|
||||
|
||||
|
||||
@naming_templates_bp.route('/api/naming-templates/<int:template_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_naming_template(template_id):
|
||||
"""Delete a naming template."""
|
||||
template = NamingTemplate.query.filter_by(
|
||||
id=template_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
return jsonify({'error': 'Template not found'}), 404
|
||||
|
||||
# Check if any tags are using this template
|
||||
from src.models import Tag
|
||||
tags_using = Tag.query.filter_by(naming_template_id=template_id).count()
|
||||
if tags_using > 0:
|
||||
return jsonify({
|
||||
'error': f'Cannot delete template: {tags_using} tag(s) are using this template'
|
||||
}), 400
|
||||
|
||||
db.session.delete(template)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@naming_templates_bp.route('/api/naming-templates/create-defaults', methods=['POST'])
|
||||
@login_required
|
||||
def create_default_naming_templates():
|
||||
"""Create default naming templates for the user if they don't have any."""
|
||||
existing_templates = NamingTemplate.query.filter_by(user_id=current_user.id).count()
|
||||
|
||||
if existing_templates > 0:
|
||||
return jsonify({'message': 'User already has naming templates'}), 200
|
||||
|
||||
templates = []
|
||||
|
||||
# Default template 1: Titre IA uniquement (default)
|
||||
template1 = NamingTemplate(
|
||||
user_id=current_user.id,
|
||||
name="Titre IA uniquement",
|
||||
template="{{ai_title}}",
|
||||
description="Utilise le titre généré par l'IA depuis le contenu de la transcription",
|
||||
is_default=True
|
||||
)
|
||||
templates.append(template1)
|
||||
|
||||
# Default template 2: Date + Titre IA
|
||||
template2 = NamingTemplate(
|
||||
user_id=current_user.id,
|
||||
name="Date + Titre IA",
|
||||
template="{{date}} - {{ai_title}}",
|
||||
description="Date de l'enregistrement suivie du titre IA — format recommandé pour le classement",
|
||||
is_default=False
|
||||
)
|
||||
templates.append(template2)
|
||||
|
||||
# Default template 3: Date, heure et titre
|
||||
template3 = NamingTemplate(
|
||||
user_id=current_user.id,
|
||||
name="Date, heure et titre",
|
||||
template="{{datetime}} {{ai_title}}",
|
||||
description="Inclut la date et l'heure avant le titre IA — utile quand plusieurs enregistrements par jour",
|
||||
is_default=False
|
||||
)
|
||||
templates.append(template3)
|
||||
|
||||
# Add all templates to database
|
||||
for template in templates:
|
||||
db.session.add(template)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'templates': [template.to_dict() for template in templates]
|
||||
}), 201
|
||||
|
||||
|
||||
@naming_templates_bp.route('/api/naming-templates/<int:template_id>/test', methods=['POST'])
|
||||
@login_required
|
||||
def test_naming_template(template_id):
|
||||
"""Test a naming template with sample data."""
|
||||
template = NamingTemplate.query.filter_by(
|
||||
id=template_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
return jsonify({'error': 'Template not found'}), 404
|
||||
|
||||
data = request.json or {}
|
||||
sample_filename = data.get('filename', 'sample-recording-2026-01-15.mp3')
|
||||
sample_date = data.get('date')
|
||||
sample_ai_title = data.get('ai_title', 'Meeting with Team')
|
||||
|
||||
# Parse sample date
|
||||
meeting_date = None
|
||||
if sample_date:
|
||||
try:
|
||||
meeting_date = datetime.fromisoformat(sample_date)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if not meeting_date:
|
||||
meeting_date = datetime.now()
|
||||
|
||||
# Apply template
|
||||
result = template.apply(
|
||||
original_filename=sample_filename,
|
||||
meeting_date=meeting_date,
|
||||
ai_title=sample_ai_title
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'result': result or '(empty - would fall back to AI title or filename)',
|
||||
'needs_ai_title': template.needs_ai_title(),
|
||||
'input': {
|
||||
'filename': sample_filename,
|
||||
'date': meeting_date.isoformat() if meeting_date else None,
|
||||
'ai_title': sample_ai_title
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@naming_templates_bp.route('/api/naming-templates/default', methods=['GET'])
|
||||
@login_required
|
||||
def get_default_naming_template():
|
||||
"""Get the user's default naming template."""
|
||||
return jsonify({
|
||||
'default_naming_template_id': current_user.default_naming_template_id
|
||||
})
|
||||
|
||||
|
||||
@naming_templates_bp.route('/api/naming-templates/default', methods=['PUT'])
|
||||
@login_required
|
||||
def set_default_naming_template():
|
||||
"""Set the user's default naming template."""
|
||||
data = request.json
|
||||
template_id = data.get('template_id') if data else None
|
||||
|
||||
if template_id:
|
||||
# Verify template belongs to user
|
||||
template = NamingTemplate.query.filter_by(
|
||||
id=template_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
return jsonify({'error': 'Template not found'}), 404
|
||||
|
||||
current_user.default_naming_template_id = template_id if template_id else None
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'default_naming_template_id': current_user.default_naming_template_id
|
||||
})
|
||||
232
src/api/push_notifications.py
Normal file
232
src/api/push_notifications.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Push Notification API Endpoints
|
||||
Handles push notification subscriptions and delivery
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from src.database import db
|
||||
from src.models.push_subscription import PushSubscription
|
||||
import json
|
||||
|
||||
|
||||
push_bp = Blueprint('push', __name__)
|
||||
|
||||
# VAPID config is loaded lazily to avoid startup issues
|
||||
_vapid_config = None
|
||||
|
||||
|
||||
def _get_vapid_config():
|
||||
"""Load VAPID configuration lazily"""
|
||||
global _vapid_config
|
||||
if _vapid_config is None:
|
||||
try:
|
||||
from src.utils.vapid_keys import VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_ENABLED
|
||||
_vapid_config = {
|
||||
'enabled': VAPID_ENABLED,
|
||||
'public_key': VAPID_PUBLIC_KEY,
|
||||
'private_key': VAPID_PRIVATE_KEY
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"[Push] Failed to load VAPID config: {e}")
|
||||
_vapid_config = {
|
||||
'enabled': False,
|
||||
'public_key': None,
|
||||
'private_key': None
|
||||
}
|
||||
return _vapid_config
|
||||
|
||||
|
||||
@push_bp.route('/api/push/config', methods=['GET'])
|
||||
def get_push_config():
|
||||
"""Get push notification configuration for client"""
|
||||
config = _get_vapid_config()
|
||||
return jsonify({
|
||||
'enabled': config['enabled'],
|
||||
'public_key': config['public_key'] if config['enabled'] else None
|
||||
})
|
||||
|
||||
|
||||
@push_bp.route('/api/push/subscribe', methods=['POST'])
|
||||
@login_required
|
||||
def subscribe():
|
||||
"""Store push subscription for current user"""
|
||||
config = _get_vapid_config()
|
||||
if not config['enabled']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Push notifications not available'
|
||||
}), 503
|
||||
|
||||
try:
|
||||
subscription_data = request.json
|
||||
|
||||
if not subscription_data or 'endpoint' not in subscription_data:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid subscription data'
|
||||
}), 400
|
||||
|
||||
# Check if subscription already exists
|
||||
existing = PushSubscription.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
endpoint=subscription_data['endpoint']
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Already subscribed',
|
||||
'subscription_id': existing.id
|
||||
})
|
||||
|
||||
# Create new subscription
|
||||
subscription = PushSubscription(
|
||||
user_id=current_user.id,
|
||||
endpoint=subscription_data['endpoint'],
|
||||
p256dh_key=subscription_data.get('keys', {}).get('p256dh', ''),
|
||||
auth_key=subscription_data.get('keys', {}).get('auth', '')
|
||||
)
|
||||
|
||||
db.session.add(subscription)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Subscription saved',
|
||||
'subscription_id': subscription.id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"[Push] Subscription error: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@push_bp.route('/api/push/unsubscribe', methods=['POST'])
|
||||
@login_required
|
||||
def unsubscribe():
|
||||
"""Remove push subscription for current user"""
|
||||
config = _get_vapid_config()
|
||||
if not config['enabled']:
|
||||
return jsonify({'success': True, 'message': 'Push notifications not enabled'})
|
||||
|
||||
try:
|
||||
subscription_data = request.json
|
||||
|
||||
if not subscription_data or 'endpoint' not in subscription_data:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Invalid subscription data'
|
||||
}), 400
|
||||
|
||||
subscription = PushSubscription.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
endpoint=subscription_data['endpoint']
|
||||
).first()
|
||||
|
||||
if subscription:
|
||||
db.session.delete(subscription)
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Subscription removed'
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Subscription not found'
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"[Push] Unsubscribe error: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
def send_push_notification(user_id, title, body, data=None, url=None):
|
||||
"""
|
||||
Send push notification to all subscriptions for a user
|
||||
|
||||
Args:
|
||||
user_id: User ID to send notification to
|
||||
title: Notification title
|
||||
body: Notification body text
|
||||
data: Optional dictionary of extra data
|
||||
url: Optional URL to open when notification is clicked
|
||||
"""
|
||||
config = _get_vapid_config()
|
||||
if not config['enabled']:
|
||||
print("[Push] Push notifications not enabled, skipping")
|
||||
return
|
||||
|
||||
try:
|
||||
from pywebpush import webpush, WebPushException
|
||||
|
||||
subscriptions = PushSubscription.query.filter_by(user_id=user_id).all()
|
||||
|
||||
if not subscriptions:
|
||||
print(f"[Push] No subscriptions found for user {user_id}")
|
||||
return
|
||||
|
||||
notification_data = {
|
||||
'title': title,
|
||||
'body': body,
|
||||
'icon': '/static/img/icon-192x192.png',
|
||||
'badge': '/static/img/icon-192x192.png',
|
||||
'data': data or {}
|
||||
}
|
||||
|
||||
if url:
|
||||
notification_data['data']['url'] = url
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for subscription in subscriptions:
|
||||
try:
|
||||
webpush(
|
||||
subscription_info={
|
||||
'endpoint': subscription.endpoint,
|
||||
'keys': {
|
||||
'p256dh': subscription.p256dh_key,
|
||||
'auth': subscription.auth_key
|
||||
}
|
||||
},
|
||||
data=json.dumps(notification_data),
|
||||
vapid_private_key=config['private_key'],
|
||||
vapid_claims={
|
||||
'sub': 'mailto:admin@speakr.app'
|
||||
}
|
||||
)
|
||||
sent_count += 1
|
||||
print(f'[Push] Sent notification to user {user_id} subscription {subscription.id}')
|
||||
|
||||
except WebPushException as e:
|
||||
failed_count += 1
|
||||
print(f'[Push] Failed to send to subscription {subscription.id}: {e}')
|
||||
|
||||
# Remove expired subscriptions
|
||||
if e.response and e.response.status_code in [404, 410]:
|
||||
print(f'[Push] Removing expired subscription {subscription.id}')
|
||||
db.session.delete(subscription)
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
print(f'[Push] Unexpected error sending to subscription {subscription.id}: {e}')
|
||||
|
||||
# Commit any deletions
|
||||
if failed_count > 0:
|
||||
db.session.commit()
|
||||
|
||||
print(f'[Push] Sent {sent_count} notifications, {failed_count} failed')
|
||||
|
||||
except ImportError:
|
||||
print("[Push] pywebpush not installed, cannot send notifications")
|
||||
except Exception as e:
|
||||
print(f"[Push] Error sending notifications: {e}")
|
||||
4080
src/api/recordings.py
Normal file
4080
src/api/recordings.py
Normal file
File diff suppressed because it is too large
Load Diff
641
src/api/shares.py
Normal file
641
src/api/shares.py
Normal file
@@ -0,0 +1,641 @@
|
||||
"""
|
||||
Sharing routes for public and internal recording shares.
|
||||
|
||||
This blueprint handles:
|
||||
- Public sharing (shareable links)
|
||||
- Internal sharing (user-to-user sharing)
|
||||
- Share management (CRUD operations)
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, jsonify, send_file, current_app
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from src.database import db
|
||||
from src.models import Recording, Share, InternalShare, SharedRecordingState, User, TranscriptChunk, ShareAuditLog
|
||||
from src.utils import md_to_html
|
||||
from src.services.audit import audit_access
|
||||
|
||||
# Configuration from environment
|
||||
ENABLE_PUBLIC_SHARING = os.environ.get('ENABLE_PUBLIC_SHARING', 'true').lower() == 'true'
|
||||
ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true'
|
||||
SHOW_USERNAMES_IN_UI = os.environ.get('SHOW_USERNAMES_IN_UI', 'false').lower() == 'true'
|
||||
ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true'
|
||||
READABLE_PUBLIC_LINKS = os.environ.get('READABLE_PUBLIC_LINKS', 'false').lower() == 'true'
|
||||
|
||||
# Create blueprint
|
||||
shares_bp = Blueprint('shares', __name__)
|
||||
|
||||
# Import has_recording_access from app context
|
||||
has_recording_access = None
|
||||
|
||||
def init_shares_helpers(_has_recording_access):
|
||||
"""Initialize helper functions from app."""
|
||||
global has_recording_access
|
||||
has_recording_access = _has_recording_access
|
||||
|
||||
|
||||
def process_transcription_for_template(transcription_str):
|
||||
"""
|
||||
Process transcription JSON into a format ready for server-side rendering.
|
||||
|
||||
Returns a dict with:
|
||||
- is_json: bool - whether transcription is valid JSON
|
||||
- has_speakers: bool - whether diarization data exists
|
||||
- segments: list - processed segments with speaker info and colors
|
||||
- speakers: list - unique speakers with colors
|
||||
- plain_text: str - plain text version for non-JSON or fallback
|
||||
"""
|
||||
if not transcription_str:
|
||||
return {'is_json': False, 'has_speakers': False, 'segments': [], 'speakers': [], 'plain_text': ''}
|
||||
|
||||
try:
|
||||
data = json.loads(transcription_str)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# Plain text transcription
|
||||
return {
|
||||
'is_json': False,
|
||||
'has_speakers': False,
|
||||
'segments': [],
|
||||
'speakers': [],
|
||||
'plain_text': transcription_str
|
||||
}
|
||||
|
||||
if not isinstance(data, list):
|
||||
return {
|
||||
'is_json': False,
|
||||
'has_speakers': False,
|
||||
'segments': [],
|
||||
'speakers': [],
|
||||
'plain_text': transcription_str
|
||||
}
|
||||
|
||||
# Check if diarized (has speaker info)
|
||||
has_speakers = any(seg.get('speaker') for seg in data)
|
||||
|
||||
# Get unique speakers and assign colors
|
||||
speakers = []
|
||||
speaker_colors = {}
|
||||
if has_speakers:
|
||||
unique_speakers = list(dict.fromkeys(seg.get('speaker') for seg in data if seg.get('speaker')))
|
||||
for i, speaker in enumerate(unique_speakers):
|
||||
color = f'speaker-color-{(i % 8) + 1}'
|
||||
speaker_colors[speaker] = color
|
||||
speakers.append({'name': speaker, 'color': color})
|
||||
|
||||
# Process segments
|
||||
segments = []
|
||||
last_speaker = None
|
||||
for seg in data:
|
||||
speaker = seg.get('speaker', '')
|
||||
segment = {
|
||||
'text': seg.get('sentence', ''),
|
||||
'speaker': speaker,
|
||||
'start_time': seg.get('start_time') or seg.get('startTime', ''),
|
||||
'end_time': seg.get('end_time') or seg.get('endTime', ''),
|
||||
'color': speaker_colors.get(speaker, 'speaker-color-1'),
|
||||
'show_speaker': speaker != last_speaker
|
||||
}
|
||||
segments.append(segment)
|
||||
last_speaker = speaker
|
||||
|
||||
# Build plain text version
|
||||
if has_speakers:
|
||||
plain_text = '\n'.join(f"[{seg['speaker']}]: {seg['text']}" for seg in segments)
|
||||
else:
|
||||
plain_text = '\n'.join(seg['text'] for seg in segments)
|
||||
|
||||
return {
|
||||
'is_json': True,
|
||||
'has_speakers': has_speakers,
|
||||
'segments': segments,
|
||||
'speakers': speakers,
|
||||
'plain_text': plain_text
|
||||
}
|
||||
|
||||
|
||||
# --- Public Sharing Routes ---
|
||||
|
||||
@shares_bp.route('/share/<string:public_id>', methods=['GET'])
|
||||
def view_shared_recording(public_id):
|
||||
"""View a publicly shared recording."""
|
||||
share = Share.query.filter_by(public_id=public_id).first_or_404()
|
||||
recording = share.recording
|
||||
|
||||
# Audit: log public share access avec dédup IP/1h pour éviter flood
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
from src.models.access_log import AccessLog
|
||||
_ip = request.remote_addr
|
||||
_cutoff = datetime.utcnow() - timedelta(hours=1)
|
||||
_recent = AccessLog.query.filter(
|
||||
AccessLog.action == 'share_view',
|
||||
AccessLog.resource_id == recording.id,
|
||||
AccessLog.ip_address == _ip,
|
||||
AccessLog.timestamp >= _cutoff
|
||||
).first()
|
||||
if not _recent:
|
||||
audit_access('share_view', 'recording', recording.id, details={'public_id': public_id, 'anonymous': True})
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
current_app.logger.warning('Audit share_view failed', exc_info=True)
|
||||
|
||||
# Process transcription for server-side rendering (only if READABLE_PUBLIC_LINKS is enabled)
|
||||
processed_transcript = None
|
||||
if READABLE_PUBLIC_LINKS:
|
||||
processed_transcript = process_transcription_for_template(recording.transcription)
|
||||
|
||||
# Create a limited dictionary for the public view
|
||||
recording_data = {
|
||||
'id': recording.id,
|
||||
'public_id': share.public_id,
|
||||
'title': recording.title,
|
||||
'participants': recording.participants,
|
||||
'transcription': recording.transcription,
|
||||
'summary': md_to_html(recording.summary) if share.share_summary else None,
|
||||
'summary_raw': recording.summary if share.share_summary else None,
|
||||
'notes': md_to_html(recording.notes) if share.share_notes else None,
|
||||
'notes_raw': recording.notes if share.share_notes else None,
|
||||
'meeting_date': f"{recording.meeting_date.isoformat()}T00:00:00" if recording.meeting_date else None,
|
||||
'mime_type': recording.mime_type,
|
||||
'audio_deleted_at': recording.audio_deleted_at.isoformat() if recording.audio_deleted_at else None,
|
||||
'audio_duration': recording.get_audio_duration()
|
||||
}
|
||||
|
||||
return render_template('share.html', recording=recording_data, transcript=processed_transcript, readable_mode=READABLE_PUBLIC_LINKS)
|
||||
|
||||
|
||||
@shares_bp.route('/share/audio/<string:public_id>')
|
||||
def get_shared_audio(public_id):
|
||||
"""Serve audio file for a publicly shared recording."""
|
||||
try:
|
||||
share = Share.query.filter_by(public_id=public_id).first_or_404()
|
||||
recording = share.recording
|
||||
if not recording or not recording.audio_path:
|
||||
return jsonify({'error': 'Recording or audio file not found'}), 404
|
||||
if not os.path.exists(recording.audio_path):
|
||||
current_app.logger.error(f"Audio file missing from server: {recording.audio_path}")
|
||||
return jsonify({'error': 'Audio file missing from server'}), 404
|
||||
return send_file(recording.audio_path, conditional=True)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error serving shared audio for public_id {public_id}: {e}", exc_info=True)
|
||||
return jsonify({'error': 'An unexpected error occurred.'}), 500
|
||||
|
||||
|
||||
@shares_bp.route('/api/recording/<int:recording_id>/share', methods=['GET'])
|
||||
@login_required
|
||||
def get_existing_share(recording_id):
|
||||
"""Check if a share already exists for this recording."""
|
||||
recording = db.session.get(Recording, recording_id)
|
||||
if not recording or recording.user_id != current_user.id:
|
||||
return jsonify({'error': 'Recording not found or you do not have permission to view it.'}), 404
|
||||
|
||||
existing_share = Share.query.filter_by(
|
||||
recording_id=recording.id,
|
||||
user_id=current_user.id
|
||||
).order_by(Share.created_at.desc()).first()
|
||||
|
||||
if existing_share:
|
||||
share_url = url_for('shares.view_shared_recording', public_id=existing_share.public_id, _external=True)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'exists': True,
|
||||
'share_url': share_url,
|
||||
'share': existing_share.to_dict()
|
||||
}), 200
|
||||
else:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'exists': False
|
||||
}), 200
|
||||
|
||||
|
||||
@shares_bp.route('/api/recording/<int:recording_id>/share', methods=['POST'])
|
||||
@login_required
|
||||
def create_share(recording_id):
|
||||
"""Create a public share link for a recording."""
|
||||
# Check if public sharing is globally enabled
|
||||
if not ENABLE_PUBLIC_SHARING:
|
||||
return jsonify({'error': 'Public sharing is not enabled on this server'}), 403
|
||||
|
||||
# Check if user has permission to create public shares
|
||||
if not current_user.can_share_publicly:
|
||||
return jsonify({'error': 'You do not have permission to create public share links. Contact your administrator.'}), 403
|
||||
|
||||
if not request.is_secure:
|
||||
return jsonify({'error': 'Sharing is only available over a secure (HTTPS) connection.'}), 403
|
||||
|
||||
recording = db.session.get(Recording, recording_id)
|
||||
if not recording or recording.user_id != current_user.id:
|
||||
return jsonify({'error': 'Recording not found or you do not have permission to share it.'}), 404
|
||||
|
||||
data = request.json
|
||||
share_summary = data.get('share_summary', True)
|
||||
share_notes = data.get('share_notes', True)
|
||||
force_new = data.get('force_new', False)
|
||||
|
||||
# Check if ANY share already exists for this recording by this user
|
||||
existing_share = Share.query.filter_by(
|
||||
recording_id=recording.id,
|
||||
user_id=current_user.id
|
||||
).order_by(Share.created_at.desc()).first()
|
||||
|
||||
if existing_share and not force_new:
|
||||
# Update the share permissions if they've changed
|
||||
if existing_share.share_summary != share_summary or existing_share.share_notes != share_notes:
|
||||
existing_share.share_summary = share_summary
|
||||
existing_share.share_notes = share_notes
|
||||
db.session.commit()
|
||||
|
||||
# Return existing share info
|
||||
share_url = url_for('shares.view_shared_recording', public_id=existing_share.public_id, _external=True)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'share_url': share_url,
|
||||
'share': existing_share.to_dict(),
|
||||
'existing': True,
|
||||
'message': 'Using existing share link for this recording'
|
||||
}), 200
|
||||
|
||||
# Create new share
|
||||
share = Share(
|
||||
recording_id=recording.id,
|
||||
user_id=current_user.id,
|
||||
share_summary=share_summary,
|
||||
share_notes=share_notes
|
||||
)
|
||||
db.session.add(share)
|
||||
db.session.commit()
|
||||
|
||||
share_url = url_for('shares.view_shared_recording', public_id=share.public_id, _external=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'share_url': share_url,
|
||||
'share': share.to_dict(),
|
||||
'existing': False
|
||||
}), 201
|
||||
|
||||
|
||||
@shares_bp.route('/api/shares', methods=['GET'])
|
||||
@login_required
|
||||
def get_shares():
|
||||
"""Get all public shares for the current user."""
|
||||
shares = Share.query.filter_by(user_id=current_user.id).order_by(Share.created_at.desc()).all()
|
||||
return jsonify([share.to_dict() for share in shares])
|
||||
|
||||
|
||||
@shares_bp.route('/api/share/<int:share_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_share(share_id):
|
||||
"""Update a public share's settings."""
|
||||
share = Share.query.filter_by(id=share_id, user_id=current_user.id).first_or_404()
|
||||
data = request.json
|
||||
|
||||
if 'share_summary' in data:
|
||||
share.share_summary = data['share_summary']
|
||||
if 'share_notes' in data:
|
||||
share.share_notes = data['share_notes']
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'share': share.to_dict()})
|
||||
|
||||
|
||||
@shares_bp.route('/api/share/<int:share_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_share(share_id):
|
||||
"""Delete a public share."""
|
||||
share = Share.query.filter_by(id=share_id, user_id=current_user.id).first_or_404()
|
||||
db.session.delete(share)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
# --- Internal Sharing Routes ---
|
||||
|
||||
@shares_bp.route('/api/users/search', methods=['GET'])
|
||||
@login_required
|
||||
def search_users():
|
||||
"""Search for users by username (for internal sharing)."""
|
||||
if not ENABLE_INTERNAL_SHARING:
|
||||
return jsonify({'error': 'Internal sharing is not enabled'}), 403
|
||||
|
||||
query = request.args.get('q', '').strip()
|
||||
|
||||
# If SHOW_USERNAMES_IN_UI is enabled and no query, return all users for quick selection
|
||||
if SHOW_USERNAMES_IN_UI and len(query) < 2:
|
||||
users = User.query.filter(User.id != current_user.id).order_by(User.username).all()
|
||||
elif len(query) < 2:
|
||||
# If usernames are hidden and no query, return empty
|
||||
return jsonify([])
|
||||
else:
|
||||
if SHOW_USERNAMES_IN_UI:
|
||||
# If usernames are shown, allow partial match (autocomplete)
|
||||
users = User.query.filter(
|
||||
User.id != current_user.id,
|
||||
User.username.ilike(f'%{query}%')
|
||||
).limit(10).all()
|
||||
else:
|
||||
# If usernames are hidden (privacy mode), require exact match only
|
||||
users = User.query.filter(
|
||||
User.id != current_user.id,
|
||||
User.username == query
|
||||
).all()
|
||||
|
||||
return jsonify([{
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email if SHOW_USERNAMES_IN_UI else None
|
||||
} for user in users])
|
||||
|
||||
|
||||
@shares_bp.route('/api/recordings/<int:recording_id>/share-internal', methods=['POST'])
|
||||
@login_required
|
||||
def share_recording_internal(recording_id):
|
||||
"""Share a recording with another user internally."""
|
||||
if not ENABLE_INTERNAL_SHARING:
|
||||
return jsonify({'error': 'Internal sharing is not enabled'}), 403
|
||||
|
||||
try:
|
||||
data = request.json
|
||||
shared_with_user_id = data.get('user_id')
|
||||
can_edit = data.get('can_edit', False)
|
||||
can_reshare = data.get('can_reshare', False)
|
||||
|
||||
if not shared_with_user_id:
|
||||
return jsonify({'error': 'User ID is required'}), 400
|
||||
|
||||
# Check recording exists and user has permission to share it
|
||||
recording = db.session.get(Recording, recording_id)
|
||||
if not recording:
|
||||
return jsonify({'error': 'Recording not found'}), 404
|
||||
|
||||
if not has_recording_access(recording, current_user, require_reshare=True):
|
||||
return jsonify({'error': 'You do not have permission to share this recording'}), 403
|
||||
|
||||
# Check target user exists
|
||||
target_user = db.session.get(User, shared_with_user_id)
|
||||
if not target_user:
|
||||
return jsonify({'error': 'Target user not found'}), 404
|
||||
|
||||
# Prevent sharing back to owner (circular share)
|
||||
if shared_with_user_id == recording.user_id:
|
||||
return jsonify({'error': 'Cannot share a recording with its owner'}), 400
|
||||
|
||||
# Prevent sharing with self
|
||||
if shared_with_user_id == current_user.id:
|
||||
return jsonify({'error': 'Cannot share a recording with yourself'}), 400
|
||||
|
||||
# Check if already shared
|
||||
existing_share = InternalShare.query.filter_by(
|
||||
recording_id=recording_id,
|
||||
shared_with_user_id=shared_with_user_id
|
||||
).first()
|
||||
|
||||
if existing_share:
|
||||
return jsonify({'error': 'Recording already shared with this user'}), 409
|
||||
|
||||
# PERMISSION VALIDATION: Validate that current user can grant the requested permissions
|
||||
requested_permissions = {'can_edit': can_edit, 'can_reshare': can_reshare}
|
||||
is_valid, error_message = InternalShare.validate_reshare_permissions(
|
||||
recording, current_user, requested_permissions
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
return jsonify({'error': error_message}), 403
|
||||
|
||||
# Get current user's permissions for audit log
|
||||
actor_permissions = InternalShare.get_user_max_permissions(recording, current_user)
|
||||
|
||||
# Create share
|
||||
share = InternalShare(
|
||||
recording_id=recording_id,
|
||||
owner_id=current_user.id,
|
||||
shared_with_user_id=shared_with_user_id,
|
||||
can_edit=can_edit,
|
||||
can_reshare=can_reshare
|
||||
)
|
||||
db.session.add(share)
|
||||
|
||||
# Create or update SharedRecordingState for the recipient
|
||||
state = SharedRecordingState.query.filter_by(
|
||||
recording_id=recording_id,
|
||||
user_id=shared_with_user_id
|
||||
).first()
|
||||
|
||||
if not state:
|
||||
# Create new state if it doesn't exist
|
||||
state = SharedRecordingState(
|
||||
recording_id=recording_id,
|
||||
user_id=shared_with_user_id,
|
||||
is_inbox=True, # New shares appear in inbox by default
|
||||
is_highlighted=False # Not favorited by default
|
||||
)
|
||||
db.session.add(state)
|
||||
else:
|
||||
# Reset to inbox if it already exists (e.g., from previous share that was deleted)
|
||||
state.is_inbox = True
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# AUDIT LOGGING: Log the share creation
|
||||
try:
|
||||
ShareAuditLog.log_share_created(
|
||||
recording_id=recording_id,
|
||||
actor_id=current_user.id,
|
||||
target_user_id=shared_with_user_id,
|
||||
permissions={'can_edit': can_edit, 'can_reshare': can_reshare},
|
||||
actor_permissions=actor_permissions,
|
||||
notes=f"Shared by {'owner' if recording.user_id == current_user.id else 'delegated user'}",
|
||||
ip_address=request.remote_addr
|
||||
)
|
||||
db.session.commit()
|
||||
except Exception as audit_error:
|
||||
# Don't fail the share if audit logging fails
|
||||
current_app.logger.error(f"Failed to log share creation: {audit_error}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'share': share.to_dict()
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Error sharing recording internally: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@shares_bp.route('/api/recordings/<int:recording_id>/shares-internal', methods=['GET'])
|
||||
@login_required
|
||||
def get_internal_shares(recording_id):
|
||||
"""Get list of users a recording is shared with, including owner."""
|
||||
if not ENABLE_INTERNAL_SHARING:
|
||||
return jsonify({'error': 'Internal sharing is not enabled'}), 403
|
||||
|
||||
# Check recording exists and user has permission to view shares
|
||||
recording = db.session.get(Recording, recording_id)
|
||||
if not recording:
|
||||
return jsonify({'error': 'Recording not found'}), 404
|
||||
|
||||
if not has_recording_access(recording, current_user, require_reshare=True):
|
||||
return jsonify({'error': 'You do not have permission to view shares for this recording'}), 403
|
||||
|
||||
# Get all internal shares
|
||||
shares = InternalShare.query.filter_by(recording_id=recording_id).all()
|
||||
shares_list = [share.to_dict() for share in shares]
|
||||
|
||||
# Add owner as first entry (owner always has full permissions)
|
||||
owner = db.session.get(User, recording.user_id)
|
||||
if owner:
|
||||
owner_entry = {
|
||||
'id': None, # No share ID for owner
|
||||
'recording_id': recording_id,
|
||||
'owner_id': owner.id,
|
||||
'owner_username': owner.username,
|
||||
'user_id': owner.id,
|
||||
'username': owner.username,
|
||||
'can_edit': True,
|
||||
'can_reshare': True,
|
||||
'is_owner': True, # Mark as owner
|
||||
'source_type': 'owner',
|
||||
'source_tag_id': None,
|
||||
'created_at': recording.created_at.isoformat() if recording.created_at else None
|
||||
}
|
||||
# Insert owner at the beginning
|
||||
shares_list.insert(0, owner_entry)
|
||||
|
||||
return jsonify({'shares': shares_list})
|
||||
|
||||
|
||||
@shares_bp.route('/api/internal-shares/<int:share_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def revoke_internal_share(share_id):
|
||||
"""Revoke an internal share with cascade revocation."""
|
||||
if not ENABLE_INTERNAL_SHARING:
|
||||
return jsonify({'error': 'Internal sharing is not enabled'}), 403
|
||||
|
||||
share = db.session.get(InternalShare, share_id)
|
||||
if not share:
|
||||
return jsonify({'error': 'Share not found'}), 404
|
||||
|
||||
# Only owner can revoke
|
||||
if share.owner_id != current_user.id:
|
||||
return jsonify({'error': 'You do not have permission to revoke this share'}), 403
|
||||
|
||||
recording_id = share.recording_id
|
||||
revoked_user_id = share.shared_with_user_id
|
||||
revoked_count = 0
|
||||
|
||||
try:
|
||||
# CASCADE REVOCATION: Find downstream shares created by the user losing access
|
||||
downstream_shares = InternalShare.find_downstream_shares(recording_id, revoked_user_id)
|
||||
|
||||
# Recursively revoke downstream shares that don't have alternate paths
|
||||
for downstream in downstream_shares:
|
||||
# Check for alternate access paths (diamond pattern protection)
|
||||
has_alternate = InternalShare.has_alternate_access_path(
|
||||
recording_id,
|
||||
downstream.shared_with_user_id,
|
||||
excluding_grantor_id=revoked_user_id
|
||||
)
|
||||
|
||||
if not has_alternate:
|
||||
# No alternate path - cascade revoke
|
||||
# Audit log cascade revocation
|
||||
try:
|
||||
ShareAuditLog.log_share_revoked(
|
||||
share_id=downstream.id,
|
||||
recording_id=recording_id,
|
||||
actor_id=current_user.id,
|
||||
target_user_id=downstream.shared_with_user_id,
|
||||
was_cascade=True,
|
||||
notes=f"Cascaded from revoking user {revoked_user_id}",
|
||||
ip_address=request.remote_addr
|
||||
)
|
||||
except Exception as audit_error:
|
||||
current_app.logger.error(f"Failed to log cascade revocation: {audit_error}")
|
||||
|
||||
db.session.delete(downstream)
|
||||
revoked_count += 1
|
||||
|
||||
# Audit log the primary revocation
|
||||
try:
|
||||
ShareAuditLog.log_share_revoked(
|
||||
share_id=share.id,
|
||||
recording_id=recording_id,
|
||||
actor_id=current_user.id,
|
||||
target_user_id=revoked_user_id,
|
||||
was_cascade=False,
|
||||
notes=f"Revoked by user {current_user.id}, cascaded to {revoked_count} downstream shares",
|
||||
ip_address=request.remote_addr
|
||||
)
|
||||
except Exception as audit_error:
|
||||
current_app.logger.error(f"Failed to log revocation: {audit_error}")
|
||||
|
||||
# Delete the primary share
|
||||
db.session.delete(share)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'revoked_count': revoked_count + 1, # Include primary share
|
||||
'cascaded': revoked_count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Error revoking internal share: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@shares_bp.route('/api/recordings/shared-with-me', methods=['GET'])
|
||||
@login_required
|
||||
def get_shared_with_me():
|
||||
"""Get recordings that have been shared with the current user."""
|
||||
if not ENABLE_INTERNAL_SHARING:
|
||||
return jsonify({'error': 'Internal sharing is not enabled'}), 403
|
||||
|
||||
try:
|
||||
# Get shares where current user is the recipient
|
||||
shares = InternalShare.query.filter_by(shared_with_user_id=current_user.id).all()
|
||||
|
||||
result = []
|
||||
for share in shares:
|
||||
recording = share.recording
|
||||
if recording and recording.status == 'COMPLETED':
|
||||
rec_data = recording.to_list_dict(viewer_user=current_user)
|
||||
# Mark as shared recording with owner info
|
||||
rec_data['is_shared'] = True
|
||||
rec_data['owner_username'] = share.owner.username if SHOW_USERNAMES_IN_UI else None
|
||||
# Don't show outgoing share counts for recordings you don't own
|
||||
rec_data['shared_with_count'] = 0
|
||||
rec_data['public_share_count'] = 0
|
||||
# Check if recording has group tags (among visible tags)
|
||||
visible_tags = recording.get_visible_tags(current_user)
|
||||
rec_data['has_group_tags'] = any(tag.is_group_tag for tag in visible_tags) if visible_tags else False
|
||||
rec_data['share_info'] = {
|
||||
'share_id': share.id,
|
||||
'owner_username': share.owner.username if SHOW_USERNAMES_IN_UI else None,
|
||||
'can_edit': share.can_edit,
|
||||
'can_reshare': share.can_reshare,
|
||||
'shared_at': share.created_at.isoformat()
|
||||
}
|
||||
result.append(rec_data)
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error fetching shared recordings: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@shares_bp.route('/api/permissions/can-share-publicly', methods=['GET'])
|
||||
@login_required
|
||||
def can_share_publicly():
|
||||
"""Check if the current user has permission to create public shares."""
|
||||
return jsonify({
|
||||
'can_share_publicly': current_user.can_share_publicly and ENABLE_PUBLIC_SHARING
|
||||
})
|
||||
583
src/api/speakers.py
Normal file
583
src/api/speakers.py
Normal file
@@ -0,0 +1,583 @@
|
||||
"""
|
||||
Speaker identification and management.
|
||||
|
||||
This blueprint was auto-generated from app.py route extraction.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, Response, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from src.database import db
|
||||
from src.models import *
|
||||
from src.utils import *
|
||||
from src.utils.ffmpeg_utils import extract_audio_segment, FFmpegError, FFmpegNotFoundError
|
||||
from src.utils.ffprobe import get_codec_info, FFProbeError
|
||||
from src.services.speaker_embedding_matcher import find_matching_speakers
|
||||
from src.services.speaker_snippets import get_speaker_snippets, get_speaker_recordings_with_snippets
|
||||
from src.services.speaker_merge import merge_speakers, preview_merge, can_merge_speakers
|
||||
|
||||
# Create blueprint
|
||||
speakers_bp = Blueprint('speakers', __name__)
|
||||
|
||||
# Configuration from environment
|
||||
ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true'
|
||||
ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true'
|
||||
USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true'
|
||||
ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true'
|
||||
USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true'
|
||||
|
||||
# Global helpers (will be injected from app)
|
||||
has_recording_access = None
|
||||
bcrypt = None
|
||||
csrf = None
|
||||
limiter = None
|
||||
|
||||
def init_speakers_helpers(**kwargs):
|
||||
"""Initialize helper functions and extensions from app."""
|
||||
global has_recording_access, bcrypt, csrf, limiter
|
||||
has_recording_access = kwargs.get('has_recording_access')
|
||||
bcrypt = kwargs.get('bcrypt')
|
||||
csrf = kwargs.get('csrf')
|
||||
limiter = kwargs.get('limiter')
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@speakers_bp.route('/speakers', methods=['GET'])
|
||||
@login_required
|
||||
def get_speakers():
|
||||
"""Get all speakers for the current user, ordered by usage frequency and recency."""
|
||||
try:
|
||||
speakers = Speaker.query.filter_by(user_id=current_user.id)\
|
||||
.order_by(Speaker.use_count.desc(), Speaker.last_used.desc())\
|
||||
.all()
|
||||
return jsonify([speaker.to_dict() for speaker in speakers])
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error fetching speakers: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
|
||||
@speakers_bp.route('/speakers/search', methods=['GET'])
|
||||
@login_required
|
||||
def search_speakers():
|
||||
"""Search speakers by name for autocomplete functionality."""
|
||||
try:
|
||||
query = request.args.get('q', '').strip()
|
||||
if not query:
|
||||
return jsonify([])
|
||||
|
||||
speakers = Speaker.query.filter_by(user_id=current_user.id)\
|
||||
.filter(Speaker.name.ilike(f'%{query}%'))\
|
||||
.order_by(Speaker.use_count.desc(), Speaker.last_used.desc())\
|
||||
.limit(10)\
|
||||
.all()
|
||||
|
||||
return jsonify([speaker.to_dict() for speaker in speakers])
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error searching speakers: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
|
||||
@speakers_bp.route('/speakers', methods=['POST'])
|
||||
@login_required
|
||||
def create_speaker():
|
||||
"""Create a new speaker or update existing one."""
|
||||
try:
|
||||
data = request.json
|
||||
name = data.get('name', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': 'Speaker name is required'}), 400
|
||||
|
||||
# Check if speaker already exists for this user
|
||||
existing_speaker = Speaker.query.filter_by(user_id=current_user.id, name=name).first()
|
||||
|
||||
if existing_speaker:
|
||||
# Update usage statistics
|
||||
existing_speaker.use_count += 1
|
||||
existing_speaker.last_used = datetime.utcnow()
|
||||
db.session.commit()
|
||||
return jsonify(existing_speaker.to_dict())
|
||||
else:
|
||||
# Create new speaker
|
||||
speaker = Speaker(
|
||||
name=name,
|
||||
user_id=current_user.id,
|
||||
use_count=1,
|
||||
created_at=datetime.utcnow(),
|
||||
last_used=datetime.utcnow()
|
||||
)
|
||||
db.session.add(speaker)
|
||||
db.session.commit()
|
||||
return jsonify(speaker.to_dict()), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Error creating speaker: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
|
||||
@speakers_bp.route('/speakers/<int:speaker_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_speaker(speaker_id):
|
||||
"""Update a speaker's name and cascade the change to all recordings."""
|
||||
try:
|
||||
speaker = Speaker.query.filter_by(id=speaker_id, user_id=current_user.id).first()
|
||||
if not speaker:
|
||||
return jsonify({'error': 'Speaker not found'}), 404
|
||||
|
||||
data = request.json
|
||||
new_name = data.get('name', '').strip()
|
||||
|
||||
if not new_name:
|
||||
return jsonify({'error': 'Speaker name cannot be empty'}), 400
|
||||
|
||||
# Check if another speaker with this name already exists for this user
|
||||
existing_speaker = Speaker.query.filter_by(user_id=current_user.id, name=new_name).first()
|
||||
if existing_speaker and existing_speaker.id != speaker_id:
|
||||
return jsonify({'error': f'A speaker named "{new_name}" already exists'}), 400
|
||||
|
||||
# Store old name for updating transcript chunks and recordings
|
||||
old_name = speaker.name
|
||||
|
||||
# Update the speaker name
|
||||
speaker.name = new_name
|
||||
|
||||
# Update all transcript chunks that reference this speaker's old name
|
||||
# This ensures the name change cascades to all recordings
|
||||
from src.models import TranscriptChunk
|
||||
chunks_updated = TranscriptChunk.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
speaker_name=old_name
|
||||
).update({'speaker_name': new_name})
|
||||
|
||||
# Update Recording.participants field (comma-separated list of speakers)
|
||||
# AND update speaker names in the transcription JSON
|
||||
recordings_updated = 0
|
||||
user_recordings = Recording.query.filter_by(user_id=current_user.id).all()
|
||||
|
||||
for recording in user_recordings:
|
||||
updated = False
|
||||
|
||||
# Update participants field if it contains the old name
|
||||
if recording.participants and old_name in recording.participants:
|
||||
# Replace exact speaker name matches in participants list
|
||||
# Handle various formats: "Ross", "Ross, John", "John, Ross", etc.
|
||||
participants_list = [p.strip() for p in recording.participants.split(',')]
|
||||
if old_name in participants_list:
|
||||
# Replace the old name with new name
|
||||
participants_list = [new_name if p == old_name else p for p in participants_list]
|
||||
recording.participants = ', '.join(participants_list)
|
||||
updated = True
|
||||
|
||||
# Update speaker names in the transcription JSON
|
||||
# This is what displays in the transcript view speaker badges
|
||||
if recording.transcription:
|
||||
try:
|
||||
transcription_data = json.loads(recording.transcription)
|
||||
|
||||
# Handle JSON format (array of segments with speaker field)
|
||||
if isinstance(transcription_data, list):
|
||||
segments_updated = False
|
||||
for segment in transcription_data:
|
||||
if segment.get('speaker') == old_name:
|
||||
segment['speaker'] = new_name
|
||||
segments_updated = True
|
||||
|
||||
if segments_updated:
|
||||
recording.transcription = json.dumps(transcription_data)
|
||||
updated = True
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# Not JSON or invalid format, skip
|
||||
pass
|
||||
|
||||
if updated:
|
||||
recordings_updated += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(
|
||||
f"Updated speaker {speaker_id} from '{old_name}' to '{new_name}': "
|
||||
f"{chunks_updated} transcript chunks, {recordings_updated} recordings"
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'speaker': speaker.to_dict(),
|
||||
'chunks_updated': chunks_updated,
|
||||
'recordings_updated': recordings_updated
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Error updating speaker: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@speakers_bp.route('/speakers/<int:speaker_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_speaker(speaker_id):
|
||||
"""Delete a speaker."""
|
||||
try:
|
||||
speaker = Speaker.query.filter_by(id=speaker_id, user_id=current_user.id).first()
|
||||
if not speaker:
|
||||
return jsonify({'error': 'Speaker not found'}), 404
|
||||
|
||||
db.session.delete(speaker)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Error deleting speaker: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
|
||||
@speakers_bp.route('/speakers/delete_all', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_all_speakers():
|
||||
"""Delete all speakers for the current user."""
|
||||
try:
|
||||
deleted_count = Speaker.query.filter_by(user_id=current_user.id).delete()
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'deleted_count': deleted_count})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Error deleting all speakers: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@speakers_bp.route('/speakers/suggestions/<int:recording_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_speaker_suggestions(recording_id):
|
||||
"""
|
||||
Get speaker suggestions based on voice embeddings from a recording.
|
||||
|
||||
For each speaker in the recording, returns matching speakers from the user's
|
||||
speaker database based on voice similarity.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'SPEAKER_00': [
|
||||
{'speaker_id': 5, 'name': 'John', 'similarity': 85.3, 'confidence': 0.92},
|
||||
...
|
||||
],
|
||||
'SPEAKER_01': [...],
|
||||
...
|
||||
}
|
||||
"""
|
||||
try:
|
||||
recording = db.session.get(Recording, recording_id)
|
||||
if not recording:
|
||||
return jsonify({'error': 'Recording not found'}), 404
|
||||
|
||||
if not has_recording_access(recording, current_user, require_edit=False):
|
||||
return jsonify({'error': 'You do not have permission to access this recording'}), 403
|
||||
|
||||
# Get speaker embeddings from recording
|
||||
if not recording.speaker_embeddings:
|
||||
return jsonify({'suggestions': {}, 'message': 'No speaker embeddings available'}), 200
|
||||
|
||||
try:
|
||||
embeddings_data = json.loads(recording.speaker_embeddings) if isinstance(recording.speaker_embeddings, str) else recording.speaker_embeddings
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return jsonify({'error': 'Invalid speaker embeddings data'}), 500
|
||||
|
||||
# Get similarity threshold from query params (default 70%)
|
||||
threshold = float(request.args.get('threshold', 0.70))
|
||||
|
||||
# Find matches for each speaker
|
||||
suggestions = {}
|
||||
for speaker_label, embedding in embeddings_data.items():
|
||||
if embedding and len(embedding) == 256: # Validate embedding dimension
|
||||
matches = find_matching_speakers(embedding, current_user.id, threshold)
|
||||
suggestions[speaker_label] = matches
|
||||
else:
|
||||
suggestions[speaker_label] = []
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'suggestions': suggestions,
|
||||
'recording_id': recording_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting speaker suggestions: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@speakers_bp.route('/speakers/<int:speaker_id>/snippets', methods=['GET'])
|
||||
@login_required
|
||||
def get_snippets(speaker_id):
|
||||
"""
|
||||
Get representative speech snippets for a speaker.
|
||||
|
||||
Returns recent quotes from recordings where this speaker appeared.
|
||||
"""
|
||||
try:
|
||||
# Verify speaker belongs to user
|
||||
speaker = Speaker.query.filter_by(id=speaker_id, user_id=current_user.id).first()
|
||||
if not speaker:
|
||||
return jsonify({'error': 'Speaker not found'}), 404
|
||||
|
||||
limit = int(request.args.get('limit', 5))
|
||||
snippets = get_speaker_snippets(speaker_id, limit)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'speaker_id': speaker_id,
|
||||
'speaker_name': speaker.name,
|
||||
'snippets': snippets
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting speaker snippets: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@speakers_bp.route('/speakers/<int:speaker_id>/recordings', methods=['GET'])
|
||||
@login_required
|
||||
def get_speaker_recordings(speaker_id):
|
||||
"""
|
||||
Get list of recordings that contain snippets from this speaker.
|
||||
|
||||
Returns recording metadata with snippet counts.
|
||||
"""
|
||||
try:
|
||||
# Verify speaker belongs to user
|
||||
speaker = Speaker.query.filter_by(id=speaker_id, user_id=current_user.id).first()
|
||||
if not speaker:
|
||||
return jsonify({'error': 'Speaker not found'}), 404
|
||||
|
||||
recordings = get_speaker_recordings_with_snippets(speaker_id)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'speaker_id': speaker_id,
|
||||
'speaker_name': speaker.name,
|
||||
'recordings': recordings
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting speaker recordings: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@speakers_bp.route('/speakers/<int:speaker_id>/clear_embeddings', methods=['POST'])
|
||||
@login_required
|
||||
def clear_speaker_embeddings(speaker_id):
|
||||
"""
|
||||
Clear all voice embeddings for a speaker.
|
||||
|
||||
This removes all voice recognition data but keeps the speaker name and metadata.
|
||||
Useful for resetting voice profiles or removing outdated/incorrect voice data.
|
||||
"""
|
||||
try:
|
||||
# Verify speaker belongs to user
|
||||
speaker = Speaker.query.filter_by(id=speaker_id, user_id=current_user.id).first()
|
||||
if not speaker:
|
||||
return jsonify({'error': 'Speaker not found'}), 404
|
||||
|
||||
# Clear all embeddings
|
||||
speaker.voice_embeddings = None
|
||||
speaker.embedding_count = 0
|
||||
speaker.confidence_score = None
|
||||
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f"Cleared voice embeddings for speaker {speaker_id} ({speaker.name})")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Voice profile cleared for {speaker.name}',
|
||||
'speaker': {
|
||||
'id': speaker.id,
|
||||
'name': speaker.name,
|
||||
'embedding_count': 0
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Error clearing speaker embeddings: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@speakers_bp.route('/speakers/snippet-audio/<int:recording_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_snippet_audio(recording_id):
|
||||
"""
|
||||
Serve a short audio snippet from a recording.
|
||||
|
||||
Query parameters:
|
||||
start: Start time in seconds (float)
|
||||
duration: Duration in seconds (float, max 5.0)
|
||||
|
||||
Returns:
|
||||
Audio file segment in the original format
|
||||
"""
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
# Get query parameters
|
||||
start_time = float(request.args.get('start', 0))
|
||||
duration = min(float(request.args.get('duration', 4.0)), 5.0) # Max 5 seconds
|
||||
|
||||
# Get the recording
|
||||
recording = db.session.get(Recording, recording_id)
|
||||
if not recording:
|
||||
return jsonify({'error': 'Recording not found'}), 404
|
||||
|
||||
if not has_recording_access(recording, current_user, require_edit=False):
|
||||
return jsonify({'error': 'You do not have permission to access this recording'}), 403
|
||||
|
||||
if recording.audio_deleted_at:
|
||||
return jsonify({'error': 'Audio file has been deleted'}), 410
|
||||
|
||||
if not recording.audio_path or not os.path.exists(recording.audio_path):
|
||||
return jsonify({'error': 'Audio file not found'}), 404
|
||||
|
||||
# Detect audio codec to pick the right output container for stream copy
|
||||
codec_to_container = {
|
||||
'mp3': ('.mp3', 'audio/mpeg'),
|
||||
'aac': ('.m4a', 'audio/mp4'),
|
||||
'opus': ('.ogg', 'audio/ogg'),
|
||||
'vorbis': ('.ogg', 'audio/ogg'),
|
||||
'flac': ('.flac', 'audio/flac'),
|
||||
'pcm_s16le': ('.wav', 'audio/wav'),
|
||||
'pcm_s24le': ('.wav', 'audio/wav'),
|
||||
'pcm_s32le': ('.wav', 'audio/wav'),
|
||||
'pcm_f32le': ('.wav', 'audio/wav'),
|
||||
}
|
||||
snippet_ext = '.mp3'
|
||||
snippet_mime = 'audio/mpeg'
|
||||
try:
|
||||
codec_info = get_codec_info(recording.audio_path, timeout=10)
|
||||
audio_codec = codec_info.get('audio_codec')
|
||||
if audio_codec and audio_codec in codec_to_container:
|
||||
snippet_ext, snippet_mime = codec_to_container[audio_codec]
|
||||
except FFProbeError:
|
||||
pass # Fall back to mp3
|
||||
|
||||
# Create temporary file for the snippet
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=snippet_ext) as tmp_file:
|
||||
output_path = tmp_file.name
|
||||
|
||||
try:
|
||||
# Use centralized FFmpeg utility to extract the audio segment
|
||||
extract_audio_segment(
|
||||
recording.audio_path,
|
||||
output_path,
|
||||
start_time,
|
||||
duration
|
||||
)
|
||||
|
||||
# Send the file
|
||||
response = send_file(
|
||||
output_path,
|
||||
mimetype=snippet_mime,
|
||||
as_attachment=False,
|
||||
download_name=f'snippet_{recording_id}_{start_time:.1f}s{snippet_ext}'
|
||||
)
|
||||
|
||||
# Clean up temporary file after sending
|
||||
@response.call_on_close
|
||||
def cleanup():
|
||||
try:
|
||||
os.unlink(output_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
return response
|
||||
|
||||
except FFmpegNotFoundError as e:
|
||||
current_app.logger.error(f"FFmpeg not found: {e}")
|
||||
try:
|
||||
os.unlink(output_path)
|
||||
except:
|
||||
pass
|
||||
return jsonify({'error': 'FFmpeg not found on server'}), 500
|
||||
except FFmpegError as e:
|
||||
current_app.logger.error(f"FFmpeg error extracting snippet: {e}")
|
||||
try:
|
||||
os.unlink(output_path)
|
||||
except:
|
||||
pass
|
||||
return jsonify({'error': 'Failed to extract audio snippet'}), 500
|
||||
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid start time or duration'}), 400
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error serving audio snippet: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@speakers_bp.route('/speakers/merge', methods=['POST'])
|
||||
@login_required
|
||||
def merge_speaker_profiles():
|
||||
"""
|
||||
Merge multiple speaker profiles into one.
|
||||
|
||||
Request body:
|
||||
{
|
||||
'target_id': 5, # Speaker to keep
|
||||
'source_ids': [6, 7, 8], # Speakers to merge into target
|
||||
'preview': false # Optional: if true, just preview without executing
|
||||
}
|
||||
|
||||
Returns merged speaker data or preview statistics.
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
target_id = data.get('target_id')
|
||||
source_ids = data.get('source_ids', [])
|
||||
preview = data.get('preview', False)
|
||||
|
||||
if not target_id:
|
||||
return jsonify({'error': 'target_id is required'}), 400
|
||||
|
||||
if not source_ids or not isinstance(source_ids, list):
|
||||
return jsonify({'error': 'source_ids must be a non-empty list'}), 400
|
||||
|
||||
# Validate speakers can be merged
|
||||
can_merge, error_msg = can_merge_speakers([target_id] + source_ids, current_user.id)
|
||||
if not can_merge:
|
||||
return jsonify({'error': error_msg}), 400
|
||||
|
||||
if preview:
|
||||
# Just return preview statistics
|
||||
preview_data = preview_merge(target_id, source_ids, current_user.id)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'preview': preview_data
|
||||
})
|
||||
else:
|
||||
# Execute the merge
|
||||
merged_speaker = merge_speakers(target_id, source_ids, current_user.id)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Successfully merged {len(source_ids)} speaker(s) into {merged_speaker.name}',
|
||||
'speaker': merged_speaker.to_dict()
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Error merging speakers: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
|
||||
299
src/api/system.py
Normal file
299
src/api/system.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
System info and configuration.
|
||||
|
||||
This blueprint was auto-generated from app.py route extraction.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, Response, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from src.database import db
|
||||
from src.models import *
|
||||
from src.utils import *
|
||||
from src.config.version import get_version
|
||||
from src.services.llm import TEXT_MODEL_BASE_URL, TEXT_MODEL_NAME
|
||||
from src.config.app_config import ASR_BASE_URL, USE_NEW_TRANSCRIPTION_ARCHITECTURE
|
||||
from src.services.token_tracking import token_tracker
|
||||
from src.services.transcription import TranscriptionCapability
|
||||
|
||||
# Create blueprint
|
||||
system_bp = Blueprint('system', __name__)
|
||||
|
||||
# Configuration from environment
|
||||
ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true'
|
||||
ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true'
|
||||
DELETION_MODE = os.environ.get('DELETION_MODE', 'full_recording') # 'audio_only' or 'full_recording'
|
||||
USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true'
|
||||
ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true'
|
||||
USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true'
|
||||
ENABLE_CHUNKING = os.environ.get('ENABLE_CHUNKING', 'true').lower() == 'true'
|
||||
SHOW_USERNAMES_IN_UI = os.environ.get('SHOW_USERNAMES_IN_UI', 'false').lower() == 'true'
|
||||
ENABLE_AUTO_EXPORT = os.environ.get('ENABLE_AUTO_EXPORT', 'false').lower() == 'true'
|
||||
ENABLE_INCOGNITO_MODE = os.environ.get('ENABLE_INCOGNITO_MODE', 'false').lower() == 'true'
|
||||
INCOGNITO_MODE_DEFAULT = os.environ.get('INCOGNITO_MODE_DEFAULT', 'false').lower() == 'true'
|
||||
VIDEO_RETENTION = os.environ.get('VIDEO_RETENTION', 'false').lower() == 'true'
|
||||
MAX_CONCURRENT_UPLOADS = int(os.environ.get('MAX_CONCURRENT_UPLOADS', '3'))
|
||||
|
||||
# Import chunking service (will be set from app)
|
||||
chunking_service = None
|
||||
|
||||
# Global helpers (will be injected from app)
|
||||
has_recording_access = None
|
||||
bcrypt = None
|
||||
csrf = None
|
||||
limiter = None
|
||||
|
||||
def init_system_helpers(**kwargs):
|
||||
"""Initialize helper functions and extensions from app."""
|
||||
global has_recording_access, bcrypt, csrf, limiter, chunking_service
|
||||
has_recording_access = kwargs.get('has_recording_access')
|
||||
bcrypt = kwargs.get('bcrypt')
|
||||
csrf = kwargs.get('csrf')
|
||||
limiter = kwargs.get('limiter')
|
||||
chunking_service = kwargs.get('chunking_service')
|
||||
|
||||
|
||||
def csrf_exempt(f):
|
||||
"""Decorator placeholder for CSRF exemption - applied after initialization."""
|
||||
from functools import wraps
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
return f(*args, **kwargs)
|
||||
wrapper._csrf_exempt = True
|
||||
return wrapper
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@system_bp.route('/api/user/preferences', methods=['POST'])
|
||||
@login_required
|
||||
def save_user_preferences():
|
||||
"""Save user preferences including UI language"""
|
||||
data = request.json
|
||||
|
||||
if 'language' in data:
|
||||
current_user.ui_language = data['language']
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Preferences saved successfully',
|
||||
'ui_language': current_user.ui_language
|
||||
})
|
||||
|
||||
|
||||
@system_bp.route('/api/user/token-budget', methods=['GET'])
|
||||
@login_required
|
||||
def get_user_token_budget():
|
||||
"""Get current user's token budget status."""
|
||||
try:
|
||||
user = current_user
|
||||
|
||||
# If user has no budget, return null to indicate unlimited
|
||||
if not user.monthly_token_budget:
|
||||
return jsonify({
|
||||
'has_budget': False,
|
||||
'budget': None,
|
||||
'usage': 0,
|
||||
'percentage': 0
|
||||
})
|
||||
|
||||
# Get current usage
|
||||
current_usage = token_tracker.get_monthly_usage(user.id)
|
||||
percentage = (current_usage / user.monthly_token_budget) * 100
|
||||
|
||||
return jsonify({
|
||||
'has_budget': True,
|
||||
'budget': user.monthly_token_budget,
|
||||
'usage': current_usage,
|
||||
'percentage': round(percentage, 1)
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting token budget for user {current_user.id}: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# --- System Info API Endpoint ---
|
||||
|
||||
|
||||
@system_bp.route('/api/system/info', methods=['GET'])
|
||||
def get_system_info():
|
||||
"""Get system information including version and model details."""
|
||||
try:
|
||||
# Use the same version detection logic as startup
|
||||
version = get_version()
|
||||
|
||||
# Get transcription connector info
|
||||
transcription_info = {
|
||||
'connector': 'unknown',
|
||||
'model': None,
|
||||
'supports_diarization': USE_ASR_ENDPOINT, # Backwards compatible default
|
||||
'supports_speaker_embeddings': False,
|
||||
}
|
||||
|
||||
if USE_NEW_TRANSCRIPTION_ARCHITECTURE:
|
||||
try:
|
||||
from src.services.transcription import get_registry
|
||||
registry = get_registry()
|
||||
connector = registry.get_active_connector()
|
||||
if connector:
|
||||
transcription_info = {
|
||||
'connector': registry.get_active_connector_name(),
|
||||
'model': getattr(connector, 'model', None), # Model name if available
|
||||
'supports_diarization': connector.supports_diarization,
|
||||
'supports_speaker_embeddings': connector.supports(TranscriptionCapability.SPEAKER_EMBEDDINGS),
|
||||
}
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"Could not get connector info: {e}")
|
||||
|
||||
# Determine ASR status from connector (new arch) or env var (legacy)
|
||||
is_asr_connector = transcription_info.get('connector') == 'asr_endpoint'
|
||||
asr_enabled = is_asr_connector or USE_ASR_ENDPOINT
|
||||
|
||||
# Determine the active transcription endpoint based on which connector is in use
|
||||
if asr_enabled:
|
||||
active_endpoint = ASR_BASE_URL
|
||||
else:
|
||||
active_endpoint = os.environ.get('TRANSCRIPTION_BASE_URL', 'https://api.openai.com/v1')
|
||||
|
||||
return jsonify({
|
||||
'version': version,
|
||||
'llm_endpoint': TEXT_MODEL_BASE_URL,
|
||||
'llm_model': TEXT_MODEL_NAME,
|
||||
'transcription_endpoint': active_endpoint, # The actual endpoint being used
|
||||
'asr_enabled': asr_enabled,
|
||||
# Legacy fields for backwards compatibility
|
||||
'whisper_endpoint': os.environ.get('TRANSCRIPTION_BASE_URL', 'https://api.openai.com/v1'),
|
||||
'asr_endpoint': ASR_BASE_URL if asr_enabled else None,
|
||||
'transcription': transcription_info,
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting system info: {e}")
|
||||
return jsonify({'error': 'Unable to retrieve system information'}), 500
|
||||
|
||||
# --- Tag API Endpoints ---
|
||||
|
||||
|
||||
@system_bp.route('/api/config', methods=['GET'])
|
||||
def get_config():
|
||||
"""Get application configuration settings for the frontend."""
|
||||
try:
|
||||
# Get configurable file size limit
|
||||
max_file_size_mb = SystemSetting.get_setting('max_file_size_mb', 250)
|
||||
|
||||
# Get chunking configuration (supports both legacy and new formats)
|
||||
chunking_info = {}
|
||||
if ENABLE_CHUNKING and chunking_service:
|
||||
mode, limit_value = chunking_service.parse_chunk_limit()
|
||||
chunking_info = {
|
||||
'chunking_enabled': True,
|
||||
'chunking_mode': mode, # 'size' or 'duration'
|
||||
'chunking_limit': limit_value, # Value in MB or seconds
|
||||
'chunking_limit_display': f"{limit_value}{'MB' if mode == 'size' else 's'}"
|
||||
}
|
||||
else:
|
||||
chunking_info = {
|
||||
'chunking_enabled': False,
|
||||
'chunking_mode': 'size',
|
||||
'chunking_limit': 20,
|
||||
'chunking_limit_display': '20MB'
|
||||
}
|
||||
|
||||
# Check if current user can delete (for authenticated requests)
|
||||
can_delete = True # Default to true for unauthenticated config requests
|
||||
try:
|
||||
from flask_login import current_user
|
||||
if current_user and current_user.is_authenticated:
|
||||
can_delete = USERS_CAN_DELETE or current_user.is_admin
|
||||
except:
|
||||
pass # If not authenticated, use default
|
||||
|
||||
# Calculate if archive toggle should be shown (only when audio-only deletion mode is active)
|
||||
enable_archive_toggle = ENABLE_AUTO_DELETION and DELETION_MODE == 'audio_only'
|
||||
|
||||
# Get connector capabilities (new architecture)
|
||||
# Defaults to USE_ASR_ENDPOINT for backwards compatibility
|
||||
connector_supports_diarization = USE_ASR_ENDPOINT
|
||||
connector_supports_speaker_count = USE_ASR_ENDPOINT # ASR endpoint supports min/max speakers
|
||||
is_asr_connector = False
|
||||
if USE_NEW_TRANSCRIPTION_ARCHITECTURE:
|
||||
try:
|
||||
from src.services.transcription import get_registry
|
||||
registry = get_registry()
|
||||
connector = registry.get_active_connector()
|
||||
if connector:
|
||||
connector_supports_diarization = connector.supports_diarization
|
||||
connector_supports_speaker_count = connector.supports_speaker_count_control
|
||||
is_asr_connector = registry.get_active_connector_name() == 'asr_endpoint'
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"Could not get connector capabilities: {e}")
|
||||
|
||||
# Derive ASR status from connector or legacy env var
|
||||
asr_enabled = is_asr_connector or USE_ASR_ENDPOINT
|
||||
|
||||
return jsonify({
|
||||
'max_file_size_mb': max_file_size_mb,
|
||||
'recording_disclaimer': SystemSetting.get_setting('recording_disclaimer', ''),
|
||||
'upload_disclaimer': SystemSetting.get_setting('upload_disclaimer', ''),
|
||||
'custom_banner': SystemSetting.get_setting('custom_banner', ''),
|
||||
'use_asr_endpoint': asr_enabled, # Derived from connector or legacy env var
|
||||
'connector_supports_diarization': connector_supports_diarization, # Connector capability
|
||||
'connector_supports_speaker_count': connector_supports_speaker_count, # Min/max speakers
|
||||
'enable_internal_sharing': ENABLE_INTERNAL_SHARING,
|
||||
'enable_archive_toggle': enable_archive_toggle,
|
||||
'show_usernames_in_ui': SHOW_USERNAMES_IN_UI,
|
||||
'can_delete_recordings': can_delete,
|
||||
'users_can_delete_enabled': USERS_CAN_DELETE,
|
||||
'enable_incognito_mode': ENABLE_INCOGNITO_MODE,
|
||||
'incognito_mode_default': INCOGNITO_MODE_DEFAULT,
|
||||
'enable_folders': SystemSetting.get_setting('enable_folders', False) == True,
|
||||
'enable_auto_export': ENABLE_AUTO_EXPORT,
|
||||
'video_retention': VIDEO_RETENTION,
|
||||
'max_concurrent_uploads': MAX_CONCURRENT_UPLOADS,
|
||||
**chunking_info
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error fetching configuration: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
|
||||
|
||||
@system_bp.route('/api/csrf-token', methods=['GET'])
|
||||
@csrf_exempt # Exempt this endpoint from CSRF protection since it's providing tokens
|
||||
def get_csrf_token():
|
||||
"""Get a fresh CSRF token for the frontend."""
|
||||
try:
|
||||
from flask_wtf.csrf import generate_csrf
|
||||
token = generate_csrf()
|
||||
current_app.logger.info("Fresh CSRF token generated successfully")
|
||||
return jsonify({'csrf_token': token})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error generating CSRF token: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# --- Flask Routes ---
|
||||
|
||||
|
||||
@system_bp.route('/api/permissions/can-delete', methods=['GET'])
|
||||
@login_required
|
||||
def check_deletion_permission():
|
||||
"""Check if the current user can delete recordings."""
|
||||
try:
|
||||
can_delete = USERS_CAN_DELETE or current_user.is_admin
|
||||
return jsonify({
|
||||
'can_delete': can_delete,
|
||||
'is_admin': current_user.is_admin,
|
||||
'users_can_delete_enabled': USERS_CAN_DELETE
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error checking deletion permissions: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
|
||||
430
src/api/tags.py
Normal file
430
src/api/tags.py
Normal file
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
Tag management and assignment.
|
||||
|
||||
This blueprint was auto-generated from app.py route extraction.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, Response, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from src.database import db
|
||||
from src.models import *
|
||||
from src.utils import *
|
||||
|
||||
# Create blueprint
|
||||
tags_bp = Blueprint('tags', __name__)
|
||||
|
||||
# Configuration from environment
|
||||
ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true'
|
||||
ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true'
|
||||
USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true'
|
||||
ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true'
|
||||
USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true'
|
||||
|
||||
# Global helpers (will be injected from app)
|
||||
has_recording_access = None
|
||||
bcrypt = None
|
||||
csrf = None
|
||||
limiter = None
|
||||
|
||||
def init_tags_helpers(**kwargs):
|
||||
"""Initialize helper functions and extensions from app."""
|
||||
global has_recording_access, bcrypt, csrf, limiter
|
||||
has_recording_access = kwargs.get('has_recording_access')
|
||||
bcrypt = kwargs.get('bcrypt')
|
||||
csrf = kwargs.get('csrf')
|
||||
limiter = kwargs.get('limiter')
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@tags_bp.route('/api/tags', methods=['GET'])
|
||||
@login_required
|
||||
def get_tags():
|
||||
"""Get all tags for the current user, including group tags they have access to."""
|
||||
# Get user's personal tags
|
||||
user_tags = Tag.query.filter_by(user_id=current_user.id, group_id=None).order_by(Tag.name).all()
|
||||
|
||||
# Get user's team memberships with roles
|
||||
memberships = GroupMembership.query.filter_by(user_id=current_user.id).all()
|
||||
team_roles = {m.group_id: m.role for m in memberships}
|
||||
team_ids = list(team_roles.keys())
|
||||
|
||||
# Get group tags for all teams the user is a member of
|
||||
team_tags = []
|
||||
if team_ids:
|
||||
team_tags = Tag.query.filter(Tag.group_id.in_(team_ids)).order_by(Tag.name).all()
|
||||
|
||||
# Build response with edit permissions
|
||||
result = []
|
||||
|
||||
# Personal tags - user can always edit their own
|
||||
for tag in user_tags:
|
||||
tag_dict = tag.to_dict()
|
||||
tag_dict['can_edit'] = True
|
||||
tag_dict['user_role'] = None
|
||||
result.append(tag_dict)
|
||||
|
||||
# Group tags - only admins can edit
|
||||
for tag in team_tags:
|
||||
tag_dict = tag.to_dict()
|
||||
user_role = team_roles.get(tag.group_id, 'member')
|
||||
tag_dict['can_edit'] = (user_role == 'admin')
|
||||
tag_dict['user_role'] = user_role
|
||||
result.append(tag_dict)
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
|
||||
@tags_bp.route('/api/tags', methods=['POST'])
|
||||
@login_required
|
||||
def create_tag():
|
||||
"""Create a new tag (personal or group tag)."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('name'):
|
||||
return jsonify({'error': 'Tag name is required'}), 400
|
||||
|
||||
group_id = data.get('group_id')
|
||||
|
||||
# If creating a group tag, verify user is admin of that group
|
||||
if group_id:
|
||||
membership = GroupMembership.query.filter_by(
|
||||
group_id=group_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not membership or membership.role != 'admin':
|
||||
return jsonify({'error': 'Only group admins can create group tags'}), 403
|
||||
|
||||
# Check if group tag with same name already exists for this group
|
||||
existing_tag = Tag.query.filter_by(name=data['name'], group_id=group_id).first()
|
||||
if existing_tag:
|
||||
return jsonify({'error': 'A tag with this name already exists for this group'}), 400
|
||||
else:
|
||||
# Check if personal tag with same name already exists for this user
|
||||
existing_tag = Tag.query.filter_by(name=data['name'], user_id=current_user.id, group_id=None).first()
|
||||
if existing_tag:
|
||||
return jsonify({'error': 'Tag with this name already exists'}), 400
|
||||
|
||||
# Handle retention_days: -1 means protected from deletion
|
||||
retention_days = data.get('retention_days')
|
||||
protect_from_deletion = False
|
||||
|
||||
if retention_days == -1:
|
||||
# -1 indicates infinite retention (protected from auto-deletion)
|
||||
protect_from_deletion = True if ENABLE_AUTO_DELETION else False
|
||||
|
||||
# Validate naming_template_id if provided
|
||||
naming_template_id = data.get('naming_template_id')
|
||||
if naming_template_id:
|
||||
from src.models import NamingTemplate
|
||||
template = NamingTemplate.query.filter_by(id=naming_template_id, user_id=current_user.id).first()
|
||||
if not template:
|
||||
return jsonify({'error': 'Naming template not found'}), 404
|
||||
|
||||
# Validate export_template_id if provided
|
||||
export_template_id = data.get('export_template_id')
|
||||
if export_template_id:
|
||||
from src.models import ExportTemplate
|
||||
template = ExportTemplate.query.filter_by(id=export_template_id, user_id=current_user.id).first()
|
||||
if not template:
|
||||
return jsonify({'error': 'Export template not found'}), 404
|
||||
|
||||
tag = Tag(
|
||||
name=data['name'],
|
||||
user_id=current_user.id,
|
||||
group_id=group_id,
|
||||
color=data.get('color', '#3B82F6'),
|
||||
custom_prompt=data.get('custom_prompt'),
|
||||
default_language=data.get('default_language'),
|
||||
default_min_speakers=data.get('default_min_speakers'),
|
||||
default_max_speakers=data.get('default_max_speakers'),
|
||||
default_hotwords=data.get('default_hotwords'),
|
||||
default_initial_prompt=data.get('default_initial_prompt'),
|
||||
protect_from_deletion=protect_from_deletion,
|
||||
retention_days=retention_days,
|
||||
auto_share_on_apply=data.get('auto_share_on_apply', True) if group_id else True,
|
||||
share_with_group_lead=data.get('share_with_group_lead', True) if group_id else True,
|
||||
naming_template_id=naming_template_id,
|
||||
export_template_id=export_template_id
|
||||
)
|
||||
|
||||
db.session.add(tag)
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Tag creation failed due to integrity constraint: {str(e)}")
|
||||
return jsonify({'error': 'A tag with this name already exists'}), 400
|
||||
|
||||
return jsonify(tag.to_dict()), 201
|
||||
|
||||
|
||||
|
||||
@tags_bp.route('/api/tags/<int:tag_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_tag(tag_id):
|
||||
"""Update a tag."""
|
||||
tag = db.session.get(Tag, tag_id)
|
||||
if not tag:
|
||||
return jsonify({'error': 'Tag not found'}), 404
|
||||
|
||||
# Check permissions
|
||||
if tag.group_id:
|
||||
# Group tag - user must be a team admin
|
||||
membership = GroupMembership.query.filter_by(
|
||||
group_id=tag.group_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not membership or membership.role != 'admin':
|
||||
return jsonify({'error': 'Only group admins can edit group tags'}), 403
|
||||
else:
|
||||
# Personal tag - must be the owner
|
||||
if tag.user_id != current_user.id:
|
||||
return jsonify({'error': 'You do not have permission to edit this tag'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
if 'name' in data:
|
||||
# Check if new name conflicts with another tag
|
||||
if tag.group_id:
|
||||
existing_tag = Tag.query.filter_by(name=data['name'], group_id=tag.group_id).filter(Tag.id != tag_id).first()
|
||||
else:
|
||||
existing_tag = Tag.query.filter_by(name=data['name'], user_id=current_user.id).filter(Tag.id != tag_id).first()
|
||||
|
||||
if existing_tag:
|
||||
return jsonify({'error': 'Another tag with this name already exists'}), 400
|
||||
tag.name = data['name']
|
||||
|
||||
# Handle group_id changes (converting between personal and group tags)
|
||||
if 'group_id' in data:
|
||||
new_group_id = data['group_id'] if data['group_id'] else None
|
||||
|
||||
# If changing to a group tag, verify user is admin of that group
|
||||
if new_group_id:
|
||||
membership = GroupMembership.query.filter_by(
|
||||
group_id=new_group_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not membership or membership.role != 'admin':
|
||||
return jsonify({'error': 'Only group admins can assign tags to groups'}), 403
|
||||
|
||||
tag.group_id = new_group_id
|
||||
|
||||
if 'color' in data:
|
||||
tag.color = data['color']
|
||||
if 'custom_prompt' in data:
|
||||
tag.custom_prompt = data['custom_prompt']
|
||||
if 'default_language' in data:
|
||||
tag.default_language = data['default_language']
|
||||
if 'default_min_speakers' in data:
|
||||
tag.default_min_speakers = data['default_min_speakers']
|
||||
if 'default_max_speakers' in data:
|
||||
tag.default_max_speakers = data['default_max_speakers']
|
||||
if 'default_hotwords' in data:
|
||||
tag.default_hotwords = data['default_hotwords'] or None
|
||||
if 'default_initial_prompt' in data:
|
||||
tag.default_initial_prompt = data['default_initial_prompt'] or None
|
||||
|
||||
# Handle retention_days: -1 means protected from deletion
|
||||
if 'retention_days' in data:
|
||||
retention_days = data['retention_days']
|
||||
|
||||
if retention_days == -1:
|
||||
# -1 indicates infinite retention (protected from auto-deletion)
|
||||
if ENABLE_AUTO_DELETION:
|
||||
tag.protect_from_deletion = True
|
||||
tag.retention_days = -1
|
||||
else:
|
||||
# Regular retention period or null (use global)
|
||||
tag.protect_from_deletion = False
|
||||
tag.retention_days = retention_days if retention_days else None
|
||||
if 'auto_share_on_apply' in data:
|
||||
# Only applicable to group tags
|
||||
if tag.group_id:
|
||||
tag.auto_share_on_apply = bool(data['auto_share_on_apply'])
|
||||
if 'share_with_group_lead' in data:
|
||||
# Only applicable to group tags
|
||||
if tag.group_id:
|
||||
tag.share_with_group_lead = bool(data['share_with_group_lead'])
|
||||
if 'naming_template_id' in data:
|
||||
naming_template_id = data['naming_template_id']
|
||||
if naming_template_id:
|
||||
from src.models import NamingTemplate
|
||||
template = NamingTemplate.query.filter_by(id=naming_template_id, user_id=current_user.id).first()
|
||||
if not template:
|
||||
return jsonify({'error': 'Naming template not found'}), 404
|
||||
tag.naming_template_id = naming_template_id if naming_template_id else None
|
||||
if 'export_template_id' in data:
|
||||
export_template_id = data['export_template_id']
|
||||
if export_template_id:
|
||||
from src.models import ExportTemplate
|
||||
template = ExportTemplate.query.filter_by(id=export_template_id, user_id=current_user.id).first()
|
||||
if not template:
|
||||
return jsonify({'error': 'Export template not found'}), 404
|
||||
tag.export_template_id = export_template_id if export_template_id else None
|
||||
|
||||
tag.updated_at = datetime.utcnow()
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Tag update failed due to integrity constraint: {str(e)}")
|
||||
return jsonify({'error': 'A tag with this name already exists'}), 400
|
||||
|
||||
return jsonify(tag.to_dict())
|
||||
|
||||
|
||||
|
||||
@tags_bp.route('/api/tags/<int:tag_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_tag(tag_id):
|
||||
"""Delete a tag."""
|
||||
tag = db.session.get(Tag, tag_id)
|
||||
if not tag:
|
||||
return jsonify({'error': 'Tag not found'}), 404
|
||||
|
||||
# Check permissions
|
||||
if tag.group_id:
|
||||
# Group tag - user must be a team admin
|
||||
membership = GroupMembership.query.filter_by(
|
||||
group_id=tag.group_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not membership or membership.role != 'admin':
|
||||
return jsonify({'error': 'Only group admins can delete group tags'}), 403
|
||||
else:
|
||||
# Personal tag - must belong to the user
|
||||
if tag.user_id != current_user.id:
|
||||
return jsonify({'error': 'You do not have permission to delete this tag'}), 403
|
||||
|
||||
db.session.delete(tag)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
|
||||
@tags_bp.route('/api/groups/<int:group_id>/tags', methods=['POST'])
|
||||
@login_required
|
||||
def create_group_tag(group_id):
|
||||
"""Create a group-scoped tag (group admins only)."""
|
||||
if not ENABLE_INTERNAL_SHARING:
|
||||
return jsonify({'error': 'Group tags require internal sharing to be enabled. Please set ENABLE_INTERNAL_SHARING=true in your configuration.'}), 403
|
||||
|
||||
# Verify team exists
|
||||
team = db.session.get(Group, group_id)
|
||||
if not team:
|
||||
return jsonify({'error': 'Group not found'}), 404
|
||||
|
||||
# Verify user is a team admin
|
||||
membership = GroupMembership.query.filter_by(
|
||||
group_id=group_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not membership or membership.role != 'admin':
|
||||
return jsonify({'error': 'Only group admins can create group tags'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': 'Tag name is required'}), 400
|
||||
|
||||
# Check if a group tag with this name already exists for this team
|
||||
existing_tag = Tag.query.filter_by(
|
||||
name=name,
|
||||
group_id=group_id
|
||||
).first()
|
||||
|
||||
if existing_tag:
|
||||
return jsonify({'error': 'A group tag with this name already exists'}), 400
|
||||
|
||||
# Validate naming_template_id if provided
|
||||
naming_template_id = data.get('naming_template_id')
|
||||
if naming_template_id:
|
||||
from src.models import NamingTemplate
|
||||
template = NamingTemplate.query.filter_by(id=naming_template_id, user_id=current_user.id).first()
|
||||
if not template:
|
||||
return jsonify({'error': 'Naming template not found'}), 404
|
||||
|
||||
# Validate export_template_id if provided
|
||||
export_template_id = data.get('export_template_id')
|
||||
if export_template_id:
|
||||
from src.models import ExportTemplate
|
||||
template = ExportTemplate.query.filter_by(id=export_template_id, user_id=current_user.id).first()
|
||||
if not template:
|
||||
return jsonify({'error': 'Export template not found'}), 404
|
||||
|
||||
# Create the group tag with all supported parameters
|
||||
tag = Tag(
|
||||
name=name,
|
||||
user_id=current_user.id, # Creator
|
||||
group_id=group_id,
|
||||
color=data.get('color', '#3B82F6'),
|
||||
custom_prompt=data.get('custom_prompt'),
|
||||
default_language=data.get('default_language'),
|
||||
default_min_speakers=data.get('default_min_speakers'),
|
||||
default_max_speakers=data.get('default_max_speakers'),
|
||||
default_hotwords=data.get('default_hotwords'),
|
||||
default_initial_prompt=data.get('default_initial_prompt'),
|
||||
protect_from_deletion=data.get('protect_from_deletion', False),
|
||||
retention_days=data.get('retention_days'),
|
||||
auto_share_on_apply=data.get('auto_share_on_apply', True), # Default to True for group tags
|
||||
share_with_group_lead=data.get('share_with_group_lead', True), # Default to True for group tags
|
||||
naming_template_id=naming_template_id,
|
||||
export_template_id=export_template_id
|
||||
)
|
||||
|
||||
db.session.add(tag)
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Tag creation failed due to integrity constraint: {str(e)}")
|
||||
return jsonify({'error': 'A tag with this name already exists'}), 400
|
||||
|
||||
return jsonify(tag.to_dict()), 201
|
||||
|
||||
|
||||
|
||||
@tags_bp.route('/api/groups/<int:group_id>/tags', methods=['GET'])
|
||||
@login_required
|
||||
def get_group_tags(group_id):
|
||||
"""Get all tags for a team (team members only)."""
|
||||
# Verify team exists
|
||||
team = db.session.get(Group, group_id)
|
||||
if not team:
|
||||
return jsonify({'error': 'Group not found'}), 404
|
||||
|
||||
# Verify user is a team member
|
||||
membership = GroupMembership.query.filter_by(
|
||||
group_id=group_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not membership:
|
||||
return jsonify({'error': 'You must be a team member to view group tags'}), 403
|
||||
|
||||
# Get all group tags
|
||||
tags = Tag.query.filter_by(group_id=group_id).all()
|
||||
|
||||
return jsonify({'tags': [tag.to_dict() for tag in tags]})
|
||||
|
||||
|
||||
|
||||
232
src/api/templates.py
Normal file
232
src/api/templates.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Transcript template management.
|
||||
|
||||
This blueprint was auto-generated from app.py route extraction.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, Response, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from src.database import db
|
||||
from src.models import *
|
||||
from src.utils import *
|
||||
|
||||
# Create blueprint
|
||||
templates_bp = Blueprint('templates', __name__)
|
||||
|
||||
# Configuration from environment
|
||||
ENABLE_INQUIRE_MODE = os.environ.get('ENABLE_INQUIRE_MODE', 'false').lower() == 'true'
|
||||
ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true'
|
||||
USERS_CAN_DELETE = os.environ.get('USERS_CAN_DELETE', 'true').lower() == 'true'
|
||||
ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true'
|
||||
USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true'
|
||||
|
||||
# Global helpers (will be injected from app)
|
||||
has_recording_access = None
|
||||
bcrypt = None
|
||||
csrf = None
|
||||
limiter = None
|
||||
|
||||
def init_templates_helpers(**kwargs):
|
||||
"""Initialize helper functions and extensions from app."""
|
||||
global has_recording_access, bcrypt, csrf, limiter
|
||||
has_recording_access = kwargs.get('has_recording_access')
|
||||
bcrypt = kwargs.get('bcrypt')
|
||||
csrf = kwargs.get('csrf')
|
||||
limiter = kwargs.get('limiter')
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@templates_bp.route('/api/transcript-templates', methods=['GET'])
|
||||
@login_required
|
||||
def get_transcript_templates():
|
||||
"""Get all transcript templates for the current user."""
|
||||
templates = TranscriptTemplate.query.filter_by(user_id=current_user.id).all()
|
||||
return jsonify([template.to_dict() for template in templates])
|
||||
|
||||
|
||||
|
||||
@templates_bp.route('/api/transcript-templates', methods=['POST'])
|
||||
@login_required
|
||||
def create_transcript_template():
|
||||
"""Create a new transcript template."""
|
||||
data = request.json
|
||||
if not data or not data.get('name') or not data.get('template'):
|
||||
return jsonify({'error': 'Name and template are required'}), 400
|
||||
|
||||
# If this is set as default, unset other defaults
|
||||
if data.get('is_default'):
|
||||
TranscriptTemplate.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
is_default=True
|
||||
).update({'is_default': False})
|
||||
|
||||
template = TranscriptTemplate(
|
||||
user_id=current_user.id,
|
||||
name=data['name'],
|
||||
template=data['template'],
|
||||
description=data.get('description'),
|
||||
is_default=data.get('is_default', False)
|
||||
)
|
||||
|
||||
db.session.add(template)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(template.to_dict()), 201
|
||||
|
||||
|
||||
|
||||
@templates_bp.route('/api/transcript-templates/<int:template_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_transcript_template(template_id):
|
||||
"""Update an existing transcript template."""
|
||||
template = TranscriptTemplate.query.filter_by(
|
||||
id=template_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
return jsonify({'error': 'Template not found'}), 404
|
||||
|
||||
data = request.json
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
# If this is set as default, unset other defaults
|
||||
if data.get('is_default'):
|
||||
TranscriptTemplate.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
is_default=True
|
||||
).update({'is_default': False})
|
||||
|
||||
template.name = data.get('name', template.name)
|
||||
template.template = data.get('template', template.template)
|
||||
template.description = data.get('description', template.description)
|
||||
template.is_default = data.get('is_default', template.is_default)
|
||||
template.updated_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(template.to_dict())
|
||||
|
||||
|
||||
|
||||
@templates_bp.route('/api/transcript-templates/<int:template_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_transcript_template(template_id):
|
||||
"""Delete a transcript template."""
|
||||
template = TranscriptTemplate.query.filter_by(
|
||||
id=template_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
return jsonify({'error': 'Template not found'}), 404
|
||||
|
||||
db.session.delete(template)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
|
||||
@templates_bp.route('/api/transcript-templates/create-defaults', methods=['POST'])
|
||||
@login_required
|
||||
def create_default_templates():
|
||||
"""Create default templates for the user if they don't have any."""
|
||||
existing_templates = TranscriptTemplate.query.filter_by(user_id=current_user.id).count()
|
||||
|
||||
if existing_templates > 0:
|
||||
return jsonify({'message': 'User already has templates'}), 200
|
||||
|
||||
templates = []
|
||||
|
||||
# Default template 1: Conversation simple
|
||||
template1 = TranscriptTemplate(
|
||||
user_id=current_user.id,
|
||||
name="Conversation simple",
|
||||
template="{{speaker}}: {{text}}",
|
||||
description="Format épuré avec noms des intervenants et texte",
|
||||
is_default=True
|
||||
)
|
||||
templates.append(template1)
|
||||
|
||||
# Default template 2: Horodaté
|
||||
template2 = TranscriptTemplate(
|
||||
user_id=current_user.id,
|
||||
name="Horodaté",
|
||||
template="[{{start_time}} - {{end_time}}] {{speaker}}: {{text}}",
|
||||
description="Format avec horodatage et noms des intervenants",
|
||||
is_default=False
|
||||
)
|
||||
templates.append(template2)
|
||||
|
||||
# Default template 3: Entrevue / Interrogatoire
|
||||
template3 = TranscriptTemplate(
|
||||
user_id=current_user.id,
|
||||
name="Entrevue / Interrogatoire",
|
||||
template="{{speaker|upper}}:\n{{text}}\n",
|
||||
description="Format questions-réponses avec noms en majuscules — idéal pour interrogatoires et entrevues",
|
||||
is_default=False
|
||||
)
|
||||
templates.append(template3)
|
||||
|
||||
# Default template 4: Consultation client
|
||||
template4 = TranscriptTemplate(
|
||||
user_id=current_user.id,
|
||||
name="Consultation client",
|
||||
template="[{{start_time}}] {{speaker}}: {{text}}",
|
||||
description="Rencontres client avec horodatage — consultation juridique, prise de notes",
|
||||
is_default=False
|
||||
)
|
||||
templates.append(template4)
|
||||
|
||||
# Default template 5: Verbatim numéroté
|
||||
template5 = TranscriptTemplate(
|
||||
user_id=current_user.id,
|
||||
name="Verbatim numéroté",
|
||||
template="{{index}} [{{start_time}}] {{speaker|upper}}: {{text}}",
|
||||
description="Transcription certifiable avec numéros de ligne et horodatage — dépositions, audiences",
|
||||
is_default=False
|
||||
)
|
||||
templates.append(template5)
|
||||
|
||||
# Default template 6: Dictée juridique
|
||||
template6 = TranscriptTemplate(
|
||||
user_id=current_user.id,
|
||||
name="Dictée juridique",
|
||||
template="{{text}}",
|
||||
description="Texte continu sans locuteur — pour dictées de lettres, mises en demeure, procédures",
|
||||
is_default=False
|
||||
)
|
||||
templates.append(template6)
|
||||
|
||||
# Default template 7: Procès-verbal formel
|
||||
template7 = TranscriptTemplate(
|
||||
user_id=current_user.id,
|
||||
name="Procès-verbal formel",
|
||||
template="• [{{start_time}}] {{speaker}}: {{text}}",
|
||||
description="Format à puces horodatées — PV de réunion, assemblées, conseil d'administration",
|
||||
is_default=False
|
||||
)
|
||||
templates.append(template7)
|
||||
|
||||
# Add all templates to database
|
||||
for template in templates:
|
||||
db.session.add(template)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'templates': [template.to_dict() for template in templates]
|
||||
}), 201
|
||||
|
||||
|
||||
|
||||
187
src/api/tokens.py
Normal file
187
src/api/tokens.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
API Token management routes.
|
||||
|
||||
This blueprint handles creating, listing, and revoking API tokens
|
||||
for user authentication.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from src.database import db
|
||||
from src.models import APIToken
|
||||
from src.utils.token_auth import hash_token
|
||||
|
||||
# Create blueprint
|
||||
tokens_bp = Blueprint('tokens', __name__, url_prefix='/api/tokens')
|
||||
|
||||
# Extensions (injected after app initialization)
|
||||
bcrypt = None
|
||||
csrf = None
|
||||
limiter = None
|
||||
|
||||
|
||||
def init_tokens_helpers(_bcrypt, _csrf, _limiter):
|
||||
"""Initialize extensions after app creation."""
|
||||
global bcrypt, csrf, limiter
|
||||
bcrypt = _bcrypt
|
||||
csrf = _csrf
|
||||
limiter = _limiter
|
||||
|
||||
|
||||
def rate_limit(limit_string):
|
||||
"""Decorator that applies rate limiting if limiter is available."""
|
||||
def decorator(f):
|
||||
from functools import wraps
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
return f(*args, **kwargs)
|
||||
wrapper._rate_limit = limit_string
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def generate_token():
|
||||
"""
|
||||
Generate a secure random API token.
|
||||
|
||||
Returns:
|
||||
str: A cryptographically secure random token
|
||||
"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
@tokens_bp.route('', methods=['GET'])
|
||||
@login_required
|
||||
def list_tokens():
|
||||
"""
|
||||
List all API tokens for the current user.
|
||||
|
||||
Returns:
|
||||
JSON: List of token objects (without the actual token values)
|
||||
"""
|
||||
tokens = APIToken.query.filter_by(user_id=current_user.id).all()
|
||||
return jsonify({
|
||||
'tokens': [token.to_dict() for token in tokens]
|
||||
})
|
||||
|
||||
|
||||
@tokens_bp.route('', methods=['POST'])
|
||||
@login_required
|
||||
@rate_limit("10 per hour")
|
||||
def create_token():
|
||||
"""
|
||||
Create a new API token for the current user.
|
||||
|
||||
Request JSON:
|
||||
name (str, optional): A friendly name for the token
|
||||
expires_in_days (int, optional): Number of days until expiration (0 = no expiration)
|
||||
|
||||
Returns:
|
||||
JSON: The new token object including the plaintext token (shown only once)
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
# Validate input
|
||||
name = data.get('name', 'Unnamed Token')
|
||||
expires_in_days = data.get('expires_in_days', 0)
|
||||
|
||||
# Validate expiration
|
||||
if not isinstance(expires_in_days, int) or expires_in_days < 0:
|
||||
return jsonify({'error': 'expires_in_days must be a non-negative integer'}), 400
|
||||
|
||||
# Generate the token
|
||||
plaintext_token = generate_token()
|
||||
token_hash = hash_token(plaintext_token)
|
||||
|
||||
# Calculate expiration date
|
||||
expires_at = None
|
||||
if expires_in_days > 0:
|
||||
expires_at = datetime.utcnow() + timedelta(days=expires_in_days)
|
||||
|
||||
# Create the token record
|
||||
api_token = APIToken(
|
||||
user_id=current_user.id,
|
||||
token_hash=token_hash,
|
||||
name=name,
|
||||
expires_at=expires_at
|
||||
)
|
||||
|
||||
db.session.add(api_token)
|
||||
db.session.commit()
|
||||
|
||||
# Return the token data INCLUDING the plaintext token
|
||||
# This is the only time the plaintext token will be shown
|
||||
response = api_token.to_dict()
|
||||
response['token'] = plaintext_token
|
||||
|
||||
return jsonify(response), 201
|
||||
|
||||
|
||||
@tokens_bp.route('/<int:token_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
@rate_limit("20 per hour")
|
||||
def revoke_token(token_id):
|
||||
"""
|
||||
Revoke (delete) an API token.
|
||||
|
||||
Args:
|
||||
token_id (int): The ID of the token to revoke
|
||||
|
||||
Returns:
|
||||
JSON: Success message
|
||||
"""
|
||||
# Find the token
|
||||
api_token = APIToken.query.filter_by(
|
||||
id=token_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not api_token:
|
||||
return jsonify({'error': 'Token not found'}), 404
|
||||
|
||||
# Delete the token
|
||||
db.session.delete(api_token)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': 'Token revoked successfully'}), 200
|
||||
|
||||
|
||||
@tokens_bp.route('/<int:token_id>', methods=['PATCH'])
|
||||
@login_required
|
||||
@rate_limit("20 per hour")
|
||||
def update_token(token_id):
|
||||
"""
|
||||
Update an API token's metadata (name only).
|
||||
|
||||
Args:
|
||||
token_id (int): The ID of the token to update
|
||||
|
||||
Request JSON:
|
||||
name (str): The new name for the token
|
||||
|
||||
Returns:
|
||||
JSON: Updated token object
|
||||
"""
|
||||
# Find the token
|
||||
api_token = APIToken.query.filter_by(
|
||||
id=token_id,
|
||||
user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if not api_token:
|
||||
return jsonify({'error': 'Token not found'}), 404
|
||||
|
||||
# Update the name
|
||||
data = request.get_json()
|
||||
new_name = data.get('name')
|
||||
|
||||
if not new_name:
|
||||
return jsonify({'error': 'name is required'}), 400
|
||||
|
||||
api_token.name = new_name
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(api_token.to_dict()), 200
|
||||
Reference in New Issue
Block a user