feat: Papierkorb + Bestaetigungsdialoge bei allen Loeschaktionen
Papierkorb: - Dateien/Ordner werden beim Loeschen in den Papierkorb verschoben (Soft-Delete) statt sofort geloescht - Papierkorb-Seite in der Sidebar mit Tabelle aller geloeschten Elemente - Pro Element: Wiederherstellen (am Originalort) oder endgueltig loeschen - "Papierkorb leeren" Button loescht alles unwiderruflich - Backend: is_trashed, trashed_at, original_parent_id Felder im File-Model - Getrashte Dateien erscheinen nicht in der normalen Dateiliste Bestaetigungsdialoge (vorher fehlend): - Kontakte: "Moechtest du XY wirklich loeschen?" - Kalender Events: Bestaetigung vor dem Loeschen - Kalender: Bestaetigung vor dem Loeschen (mit Hinweis auf Events) - E-Mail Nachrichten: Bestaetigung mit Betreff-Vorschau - Share-Link Dateien: Bestaetigung beim Loeschen aus geteiltem Ordner - Admin SFTP-Backup-Ziele: Bestaetigung - Admin Email-Konten: Bestaetigung Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+118
-8
@@ -62,8 +62,8 @@ def list_files():
|
||||
user = request.current_user
|
||||
parent_id = request.args.get('parent_id', None, type=int)
|
||||
|
||||
# Own files in this folder
|
||||
query = File.query.filter_by(owner_id=user.id, parent_id=parent_id)
|
||||
# Own files in this folder (exclude trashed)
|
||||
query = File.query.filter_by(owner_id=user.id, parent_id=parent_id, is_trashed=False)
|
||||
files = query.order_by(File.is_folder.desc(), File.name).all()
|
||||
|
||||
# Shared files at root level
|
||||
@@ -328,7 +328,7 @@ def update_file(file_id):
|
||||
return jsonify(f.to_dict()), 200
|
||||
|
||||
|
||||
# --- Delete ---
|
||||
# --- Delete (soft-delete -> trash) ---
|
||||
|
||||
@api_bp.route('/files/<int:file_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
@@ -336,21 +336,37 @@ def delete_file(file_id):
|
||||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'admin')
|
||||
if err:
|
||||
# Owner can always delete
|
||||
f = db.session.get(File, file_id)
|
||||
if not f or f.owner_id != user.id:
|
||||
return jsonify({'error': 'Zugriff verweigert'}), 403
|
||||
|
||||
_delete_recursive(f, user.id)
|
||||
# Soft-delete: move to trash
|
||||
_trash_recursive(f)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Geloescht'}), 200
|
||||
return jsonify({'message': 'In Papierkorb verschoben'}), 200
|
||||
|
||||
|
||||
def _delete_recursive(file_obj, user_id):
|
||||
def _trash_recursive(file_obj):
|
||||
"""Move file/folder to trash (soft-delete)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
if not file_obj.is_trashed:
|
||||
file_obj.original_parent_id = file_obj.parent_id
|
||||
file_obj.parent_id = None
|
||||
file_obj.is_trashed = True
|
||||
file_obj.trashed_at = now
|
||||
if file_obj.is_folder:
|
||||
children = File.query.filter_by(parent_id=file_obj.id, is_trashed=False).all()
|
||||
for child in children:
|
||||
child.is_trashed = True
|
||||
child.trashed_at = now
|
||||
|
||||
|
||||
def _delete_permanent(file_obj):
|
||||
"""Permanently delete a file/folder and its disk data."""
|
||||
if file_obj.is_folder:
|
||||
children = File.query.filter_by(parent_id=file_obj.id).all()
|
||||
for child in children:
|
||||
_delete_recursive(child, user_id)
|
||||
_delete_permanent(child)
|
||||
else:
|
||||
if file_obj.storage_path:
|
||||
filepath = Path(current_app.config['UPLOAD_PATH']) / str(file_obj.owner_id) / file_obj.storage_path
|
||||
@@ -359,6 +375,100 @@ def _delete_recursive(file_obj, user_id):
|
||||
db.session.delete(file_obj)
|
||||
|
||||
|
||||
# --- Trash / Papierkorb ---
|
||||
|
||||
@api_bp.route('/files/trash', methods=['GET'])
|
||||
@token_required
|
||||
def list_trash():
|
||||
"""List all trashed files for the current user."""
|
||||
user = request.current_user
|
||||
trashed = File.query.filter_by(owner_id=user.id, is_trashed=True)\
|
||||
.filter(File.original_parent_id.isnot(None) | (File.original_parent_id.is_(None)))\
|
||||
.order_by(File.trashed_at.desc()).all()
|
||||
|
||||
# Only show top-level trashed items (not children of trashed folders)
|
||||
top_level = []
|
||||
trashed_folder_ids = {f.id for f in trashed if f.is_folder}
|
||||
for f in trashed:
|
||||
# Show if parent is not also trashed
|
||||
parent_trashed = False
|
||||
if f.original_parent_id:
|
||||
parent = db.session.get(File, f.original_parent_id)
|
||||
if parent and parent.is_trashed:
|
||||
parent_trashed = True
|
||||
if not parent_trashed:
|
||||
top_level.append(f.to_dict())
|
||||
|
||||
return jsonify(top_level), 200
|
||||
|
||||
|
||||
@api_bp.route('/files/trash/<int:file_id>/restore', methods=['POST'])
|
||||
@token_required
|
||||
def restore_from_trash(file_id):
|
||||
"""Restore a file/folder from trash."""
|
||||
user = request.current_user
|
||||
f = db.session.get(File, file_id)
|
||||
if not f or f.owner_id != user.id or not f.is_trashed:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
# Restore to original parent (or root if parent no longer exists)
|
||||
original_parent = db.session.get(File, f.original_parent_id) if f.original_parent_id else None
|
||||
if original_parent and not original_parent.is_trashed:
|
||||
f.parent_id = f.original_parent_id
|
||||
else:
|
||||
f.parent_id = None
|
||||
|
||||
f.is_trashed = False
|
||||
f.trashed_at = None
|
||||
f.original_parent_id = None
|
||||
|
||||
# Also restore children
|
||||
if f.is_folder:
|
||||
_restore_children(f)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Wiederhergestellt'}), 200
|
||||
|
||||
|
||||
def _restore_children(folder):
|
||||
children = File.query.filter_by(is_trashed=True).all()
|
||||
for child in children:
|
||||
# Check if this child was inside the restored folder
|
||||
if child.original_parent_id == folder.id or child.parent_id == folder.id:
|
||||
child.is_trashed = False
|
||||
child.trashed_at = None
|
||||
child.parent_id = folder.id
|
||||
child.original_parent_id = None
|
||||
if child.is_folder:
|
||||
_restore_children(child)
|
||||
|
||||
|
||||
@api_bp.route('/files/trash/<int:file_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_permanently(file_id):
|
||||
"""Permanently delete a trashed file."""
|
||||
user = request.current_user
|
||||
f = db.session.get(File, file_id)
|
||||
if not f or f.owner_id != user.id or not f.is_trashed:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
_delete_permanent(f)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Endgueltig geloescht'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/files/trash/empty', methods=['POST'])
|
||||
@token_required
|
||||
def empty_trash():
|
||||
"""Permanently delete all trashed files."""
|
||||
user = request.current_user
|
||||
trashed = File.query.filter_by(owner_id=user.id, is_trashed=True).all()
|
||||
for f in trashed:
|
||||
_delete_permanent(f)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Papierkorb geleert'}), 200
|
||||
|
||||
|
||||
# --- Permissions ---
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/permissions', methods=['GET'])
|
||||
|
||||
@@ -15,6 +15,9 @@ class File(db.Model):
|
||||
size = db.Column(db.BigInteger, default=0)
|
||||
storage_path = db.Column(db.String(500), nullable=True) # UUID-based path on disk
|
||||
checksum = db.Column(db.String(64), nullable=True) # SHA-256 for sync
|
||||
is_trashed = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||
trashed_at = db.Column(db.DateTime, nullable=True)
|
||||
original_parent_id = db.Column(db.Integer, nullable=True) # to restore to original location
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
@@ -28,7 +31,7 @@ class File(db.Model):
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
d = {
|
||||
'id': self.id,
|
||||
'owner_id': self.owner_id,
|
||||
'parent_id': self.parent_id,
|
||||
@@ -39,6 +42,10 @@ class File(db.Model):
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
if self.is_trashed:
|
||||
d['is_trashed'] = True
|
||||
d['trashed_at'] = self.trashed_at.isoformat() if self.trashed_at else None
|
||||
return d
|
||||
|
||||
|
||||
class FilePermission(db.Model):
|
||||
|
||||
Reference in New Issue
Block a user