Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0)

This commit is contained in:
InnovA AI
2026-03-16 21:47:37 +00:00
commit 42772a31ed
365 changed files with 103572 additions and 0 deletions

0
src/api/__init__.py Normal file
View 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

File diff suppressed because it is too large Load Diff

107
src/api/audit.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
})

View 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

File diff suppressed because it is too large Load Diff

641
src/api/shares.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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