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