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:
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
# Lock expires after 5 minutes without heartbeat
|
||||
LOCK_TIMEOUT_MINUTES = 5
|
||||
|
||||
|
||||
class FileLock(db.Model):
|
||||
__tablename__ = 'file_locks'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
file_id = db.Column(db.Integer, db.ForeignKey('files.id'), unique=True, nullable=False, index=True)
|
||||
locked_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
locked_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
heartbeat_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
client_info = db.Column(db.String(255), nullable=True) # e.g. "Desktop-Client Windows"
|
||||
|
||||
file = db.relationship('File', backref=db.backref('lock', uselist=False))
|
||||
user = db.relationship('User', backref='file_locks')
|
||||
|
||||
def is_expired(self):
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=LOCK_TIMEOUT_MINUTES)
|
||||
return self.heartbeat_at.replace(tzinfo=timezone.utc) < cutoff
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'file_id': self.file_id,
|
||||
'locked_by': self.locked_by,
|
||||
'locked_by_username': self.user.username if self.user else None,
|
||||
'locked_at': self.locked_at.isoformat() if self.locked_at else None,
|
||||
'heartbeat_at': self.heartbeat_at.isoformat() if self.heartbeat_at else None,
|
||||
'client_info': self.client_info,
|
||||
'is_expired': self.is_expired(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def cleanup_expired():
|
||||
"""Remove all expired locks."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=LOCK_TIMEOUT_MINUTES)
|
||||
expired = FileLock.query.filter(FileLock.heartbeat_at < cutoff).all()
|
||||
count = len(expired)
|
||||
for lock in expired:
|
||||
db.session.delete(lock)
|
||||
if count:
|
||||
db.session.commit()
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
def get_lock(file_id):
|
||||
"""Get active (non-expired) lock for a file, cleaning up expired ones."""
|
||||
lock = FileLock.query.filter_by(file_id=file_id).first()
|
||||
if lock and lock.is_expired():
|
||||
db.session.delete(lock)
|
||||
db.session.commit()
|
||||
return None
|
||||
return lock
|
||||
Reference in New Issue
Block a user