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:
Stefan Hacker
2026-04-11 20:50:19 +02:00
parent 1ee80e650d
commit 82f3091f2e
10 changed files with 423 additions and 26 deletions
+118 -8
View File
@@ -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'])
+8 -1
View File
@@ -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):