feat: File Locking System (Ein-/Auschecken) + Konflikt-Email
Backend - FileLock Model + API: - POST /files/<id>/lock - Datei auschecken (sperren) - POST /files/<id>/unlock - Datei einchecken (entsperren) - POST /files/<id>/heartbeat - "Datei noch offen" (alle 60s) - GET /files/<id>/lock-status - Sperrstatus abfragen - GET /files/locks - Alle aktiven Sperren auflisten - Auto-Unlock: Kein Heartbeat seit 5 Min -> Sperre wird freigegeben - 423 Locked wenn bereits von anderem User gesperrt - Admin kann fremde Sperren aufheben Dateiliste + Sync-API: - Lock-Info (locked, locked_by, locked_at) pro Datei mitgeliefert - Sync-Tree enthaelt Lock-Status fuer Desktop/Mobile-Clients Web-UI: - Schloss-Icon mit Benutzername bei gesperrten Dateien - Tooltip: "Ausgecheckt von Adam seit 14:30" - Gesperrte Dateien: "Oeffnen nicht moeglich" Toast-Meldung (eigene Sperren sind erlaubt) Konflikt-Email an Admin: - Wer hat die Konflikt-Kopie erstellt (Name + Email) - Welche Datei (Name + Ordnerpfad) - Name der Konflikt-Kopie - Von wem gesperrt (Name + Email + seit wann) - Erklaerungstext was passiert ist Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ from app.api import api_bp
|
||||
from app.api.auth import token_required
|
||||
from app.extensions import db, bcrypt
|
||||
from app.models.file import File, FilePermission, ShareLink
|
||||
from app.models.file_lock import FileLock
|
||||
|
||||
|
||||
def _user_upload_dir(user_id):
|
||||
@@ -82,6 +83,11 @@ def list_files():
|
||||
d = f.to_dict()
|
||||
d['has_shares'] = ShareLink.query.filter_by(file_id=f.id).count() > 0
|
||||
d['has_permissions'] = FilePermission.query.filter_by(file_id=f.id).count() > 0
|
||||
lock = FileLock.get_lock(f.id)
|
||||
if lock:
|
||||
d['locked'] = True
|
||||
d['locked_by'] = lock.user.username
|
||||
d['locked_at'] = lock.locked_at.isoformat()
|
||||
result.append(d)
|
||||
for f in shared:
|
||||
d = f.to_dict()
|
||||
@@ -975,6 +981,104 @@ def delete_share_link(token):
|
||||
return jsonify({'message': 'Link geloescht'}), 200
|
||||
|
||||
|
||||
# --- File Locking ---
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/lock', methods=['POST'])
|
||||
@token_required
|
||||
def lock_file(file_id):
|
||||
"""Lock a file (check out). Prevents others from opening/editing."""
|
||||
user = request.current_user
|
||||
f = db.session.get(File, file_id)
|
||||
if not f:
|
||||
return jsonify({'error': 'Datei nicht gefunden'}), 404
|
||||
|
||||
# Check existing lock
|
||||
existing = FileLock.get_lock(file_id)
|
||||
if existing:
|
||||
if existing.locked_by == user.id:
|
||||
# Already locked by this user - refresh heartbeat
|
||||
existing.heartbeat_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
return jsonify(existing.to_dict()), 200
|
||||
return jsonify({
|
||||
'error': f'Datei wird von {existing.user.username} bearbeitet',
|
||||
'locked_by': existing.user.username,
|
||||
'locked_at': existing.locked_at.isoformat(),
|
||||
}), 423 # 423 Locked
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
lock = FileLock(
|
||||
file_id=file_id,
|
||||
locked_by=user.id,
|
||||
client_info=data.get('client_info', ''),
|
||||
)
|
||||
db.session.add(lock)
|
||||
db.session.commit()
|
||||
return jsonify(lock.to_dict()), 200
|
||||
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/unlock', methods=['POST'])
|
||||
@token_required
|
||||
def unlock_file(file_id):
|
||||
"""Unlock a file (check in)."""
|
||||
user = request.current_user
|
||||
lock = FileLock.get_lock(file_id)
|
||||
if not lock:
|
||||
return jsonify({'message': 'Datei war nicht gesperrt'}), 200
|
||||
|
||||
if lock.locked_by != user.id and user.role != 'admin':
|
||||
return jsonify({'error': 'Nur der Sperrer oder ein Admin kann entsperren'}), 403
|
||||
|
||||
db.session.delete(lock)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Datei entsperrt'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/heartbeat', methods=['POST'])
|
||||
@token_required
|
||||
def heartbeat_file(file_id):
|
||||
"""Heartbeat - signal that the file is still being edited."""
|
||||
user = request.current_user
|
||||
lock = FileLock.get_lock(file_id)
|
||||
if not lock:
|
||||
return jsonify({'error': 'Keine Sperre vorhanden'}), 404
|
||||
|
||||
if lock.locked_by != user.id:
|
||||
return jsonify({'error': 'Sperre gehoert einem anderen Benutzer'}), 403
|
||||
|
||||
lock.heartbeat_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Heartbeat aktualisiert'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/lock-status', methods=['GET'])
|
||||
@token_required
|
||||
def lock_status(file_id):
|
||||
"""Check if a file is locked."""
|
||||
lock = FileLock.get_lock(file_id)
|
||||
if not lock:
|
||||
return jsonify({'locked': False}), 200
|
||||
|
||||
return jsonify({
|
||||
'locked': True,
|
||||
'locked_by': lock.user.username,
|
||||
'locked_by_id': lock.locked_by,
|
||||
'locked_at': lock.locked_at.isoformat(),
|
||||
'client_info': lock.client_info,
|
||||
}), 200
|
||||
|
||||
|
||||
@api_bp.route('/files/locks', methods=['GET'])
|
||||
@token_required
|
||||
def list_locks():
|
||||
"""List all active locks (for admin overview or sync clients)."""
|
||||
# Cleanup expired first
|
||||
FileLock.cleanup_expired()
|
||||
|
||||
locks = FileLock.query.all()
|
||||
return jsonify([l.to_dict() for l in locks]), 200
|
||||
|
||||
|
||||
# --- Sync API ---
|
||||
|
||||
@api_bp.route('/sync/tree', methods=['GET'])
|
||||
@@ -996,6 +1100,10 @@ def sync_tree():
|
||||
'checksum': f.checksum,
|
||||
'updated_at': f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
lock = FileLock.get_lock(f.id)
|
||||
if lock:
|
||||
entry['locked'] = True
|
||||
entry['locked_by'] = lock.user.username
|
||||
if f.is_folder:
|
||||
entry['children'] = _build_tree(f.id)
|
||||
result.append(entry)
|
||||
|
||||
Reference in New Issue
Block a user