From 82f3091f2ebe699605945572df0773da133bf8ba Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Sat, 11 Apr 2026 20:50:19 +0200 Subject: [PATCH] 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) --- backend/app/api/files.py | 126 +++++++++++++++++++-- backend/app/models/file.py | 9 +- frontend/src/router/index.js | 5 + frontend/src/views/AdminView.vue | 50 +++++++-- frontend/src/views/AppLayout.vue | 5 + frontend/src/views/CalendarView.vue | 24 +++- frontend/src/views/ContactsView.vue | 25 ++++- frontend/src/views/EmailView.vue | 13 ++- frontend/src/views/ShareView.vue | 27 ++++- frontend/src/views/TrashView.vue | 165 ++++++++++++++++++++++++++++ 10 files changed, 423 insertions(+), 26 deletions(-) create mode 100644 frontend/src/views/TrashView.vue diff --git a/backend/app/api/files.py b/backend/app/api/files.py index 22278c5..d7ada5f 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -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/', 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//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/', 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//permissions', methods=['GET']) diff --git a/backend/app/models/file.py b/backend/app/models/file.py index 3509969..380cddc 100644 --- a/backend/app/models/file.py +++ b/backend/app/models/file.py @@ -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): diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 7727c41..8d7945b 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -28,6 +28,11 @@ const routes = [ name: 'Files', component: () => import('../views/FilesView.vue'), }, + { + path: 'trash', + name: 'Trash', + component: () => import('../views/TrashView.vue'), + }, { path: 'calendar', name: 'Calendar', diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 2e831ef..6d4160c 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -180,7 +180,7 @@