Files
wizarr/app/services/expiry.py
Matthieu B bf223c894e Add delay to prevent hammering the media server's database during user deletion and disabling
Increase request timeout for media client API calls to  improve reliability closes #995 closes #1014
2025-11-19 15:10:29 +01:00

362 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import datetime
import logging
import time
from app.extensions import db
from app.models import ExpiredUser, Invitation, User, invitation_servers
from app.services.media.service import delete_user, disable_user
def calculate_user_expiry(
invitation: Invitation, server_id: int | None = None
) -> datetime.datetime | None:
"""
Calculate when a user should expire based on the invitation's duration.
If server_id is provided, checks for server-specific expiry first,
then falls back to invitation-level expiry.
Args:
invitation: The invitation used to create the user
server_id: Optional server ID to check for server-specific expiry
Returns:
datetime.datetime | None: The expiry date, or None if no expiry
"""
# Check for server-specific expiry first
if server_id:
server_expiry = get_server_specific_expiry(invitation.id, server_id)
if server_expiry:
return server_expiry
# Fall back to invitation-level duration
if not invitation.duration:
return None
try:
days = int(invitation.duration)
return datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=days)
except (ValueError, TypeError):
logging.warning(
f"Invalid duration '{invitation.duration}' for invitation {invitation.id}"
)
return None
def get_server_specific_expiry(
invitation_id: int, server_id: int
) -> datetime.datetime | None:
"""
Get server-specific expiry date for an invitation-server combination.
Args:
invitation_id: The invitation ID
server_id: The server ID
Returns:
datetime.datetime | None: The server-specific expiry date, or None
"""
result = db.session.execute(
invitation_servers.select().where(
(invitation_servers.c.invite_id == invitation_id)
& (invitation_servers.c.server_id == server_id)
)
).first()
return result.expires if result else None
def set_server_specific_expiry(
invitation_id: int, server_id: int, expires: datetime.datetime | None
) -> None:
"""
Set server-specific expiry date for an invitation-server combination.
Args:
invitation_id: The invitation ID
server_id: The server ID
expires: The expiry date to set, or None to clear
"""
db.session.execute(
invitation_servers.update()
.where(
(invitation_servers.c.invite_id == invitation_id)
& (invitation_servers.c.server_id == server_id)
)
.values(expires=expires)
)
db.session.commit()
def delete_user_if_expired() -> list[int]:
"""
Find users whose `expires` < now, delete them from their associated media servers
and from the Wizarr DB. Returns a list of db IDs that were removed.
This function is multi-server aware and will delete users from their specific
servers rather than assuming a single global server.
"""
now = datetime.datetime.now(datetime.UTC)
expired_rows = User.query.filter(
User.expires.is_not(None), # not null
User.expires < now,
).all()
deleted: list[int] = []
for user in expired_rows:
# Use a nested transaction (savepoint) so if deletion fails,
# we can rollback the ExpiredUser creation too
savepoint = db.session.begin_nested()
try:
# Log the user to expired_users table before deletion
expired_user = ExpiredUser(
original_user_id=user.id,
username=user.username,
email=user.email,
invitation_code=user.code,
server_id=user.server_id,
expired_at=user.expires,
deleted_at=datetime.datetime.now(datetime.UTC),
)
db.session.add(expired_user)
db.session.flush() # Ensure it's saved before we delete the user
# Delete the user (handles server-specific deletion internally)
delete_user(user.id)
deleted.append(user.id)
logging.info(
"🗑️ Expired user %s (%s) logged and deleted", user.id, user.username
)
savepoint.commit() # Commit the savepoint on success
# Add delay to prevent hammering the media server's database
time.sleep(1)
except Exception as exc:
# Rollback the savepoint - this removes the ExpiredUser record
# and keeps the User record for retry on next scheduler run
savepoint.rollback()
logging.error(
"Failed to delete expired user %s %s. Will retry on next run.",
user.id,
exc,
)
db.session.commit()
return deleted
def get_server_disable_capabilities() -> dict[str, bool]:
"""Returns a mapping of server types to whether they support user disabling.
Returns:
dict: Server type -> supports disable (True/False)
"""
return {
"jellyfin": True,
"emby": True, # Inherits from Jellyfin
"plex": False, # Only supports deletion via removeFriend()
"audiobookshelf": True,
"kavita": True, # Removes library access
"komga": True, # Removes library access
"romm": True,
"navidrome": False, # Not supported
"drop": False, # Not supported
}
def disable_or_delete_user_if_expired() -> list[int]:
"""
Find users whose `expires` < now, and either disable or delete them based on
the expiry_action setting. Returns a list of db IDs that were processed.
This function is multi-server aware and will handle users from their specific
servers rather than assuming a single global server.
"""
from app.models import Settings
# Get the expiry action setting, default to delete for backward compatibility
expiry_action_setting = Settings.query.filter_by(key="expiry_action").first()
expiry_action = expiry_action_setting.value if expiry_action_setting else "delete"
now = datetime.datetime.now(datetime.UTC)
expired_rows = User.query.filter(
User.expires.is_not(None), # not null
User.expires < now,
).all()
processed: list[int] = []
for user in expired_rows:
# Use a nested transaction (savepoint) so if deletion/disabling fails,
# we can rollback the ExpiredUser creation too
savepoint = db.session.begin_nested()
try:
# Log the user to expired_users table before processing
expired_user = ExpiredUser(
original_user_id=user.id,
username=user.username,
email=user.email,
invitation_code=user.code,
server_id=user.server_id,
expired_at=user.expires,
deleted_at=datetime.datetime.now(datetime.UTC),
)
db.session.add(expired_user)
db.session.flush() # Ensure it's saved before we process the user
# Determine action based on setting and server capability
should_disable = (
expiry_action == "disable"
and user.server
and get_server_disable_capabilities().get(
user.server.server_type, False
)
)
if should_disable:
# Try to disable the user using the service function
try:
if disable_user(user.id):
# Successfully disabled the user
processed.append(user.id)
logging.info(
"🔒 Expired user %s (%s) disabled on %s",
user.id,
user.username,
user.server.server_type if user.server else "unknown",
)
savepoint.commit() # Commit the savepoint on success
# Add delay to prevent hammering the media server's database
time.sleep(1)
else:
# Disable failed, fallback to deletion
raise Exception("Disable operation failed")
except Exception as disable_exc:
logging.warning(
"Failed to disable user %s, falling back to deletion: %s",
user.id,
disable_exc,
)
# Fallback to deletion using service function
delete_user(user.id)
processed.append(user.id)
logging.info(
"🗑️ Expired user %s (%s) deleted (disable fallback)",
user.id,
user.username,
)
savepoint.commit() # Commit the savepoint on success
# Add delay to prevent hammering the media server's database
time.sleep(1)
else:
# Delete the user (either by setting or server doesn't support disable)
delete_user(user.id)
processed.append(user.id)
action_reason = (
"setting" if expiry_action == "delete" else "unsupported"
)
logging.info(
"🗑️ Expired user %s (%s) deleted (%s)",
user.id,
user.username,
action_reason,
)
savepoint.commit() # Commit the savepoint on success
# Add delay to prevent hammering the media server's database
time.sleep(1)
except Exception as exc:
# Rollback the savepoint - this removes the ExpiredUser record
# and keeps the User record for retry on next scheduler run
savepoint.rollback()
logging.error(
"Failed to process expired user %s %s. Will retry on next run.",
user.id,
exc,
)
db.session.commit()
return processed
def cleanup_expired_user_by_email(email: str) -> None:
"""
Remove expired user entries when a new user with the same email is created.
Args:
email: The email address to clean up from expired users
"""
if not email:
return
expired_users = ExpiredUser.query.filter_by(email=email).all()
for expired_user in expired_users:
db.session.delete(expired_user)
logging.info(
"🔄 Removed expired user record for %s (email: %s) - user re-added",
expired_user.username,
email,
)
if expired_users:
db.session.commit()
def get_expired_users() -> list[ExpiredUser]:
"""
Get all expired users for display in the admin interface.
Returns:
List of ExpiredUser objects ordered by deletion date (most recent first)
"""
return (
ExpiredUser.query.options(db.joinedload(ExpiredUser.server))
.order_by(ExpiredUser.deleted_at.desc())
.all()
)
def get_expiring_this_week_users() -> list[dict]:
"""
Get all active users whose expiry date is within the next 7 days.
Returns:
List of dictionaries with user data and calculated days left
"""
now = datetime.datetime.now(datetime.UTC)
one_week_from_now = now + datetime.timedelta(days=7)
users = (
User.query.options(db.joinedload(User.server), db.joinedload(User.identity))
.filter(
User.expires.is_not(None), # Has an expiry date
User.expires > now, # Not already expired
User.expires <= one_week_from_now, # Expires within a week
)
.order_by(User.expires.asc())
.all()
)
# Add calculated days left to each user
result = []
for user in users:
# Ensure user.expires is timezone-aware for comparison
# Database stores naive UTC, so add timezone info if missing
user_expires = user.expires
if user_expires.tzinfo is None:
user_expires = user_expires.replace(tzinfo=datetime.UTC)
days_left = (user_expires - now).total_seconds() / 86400
days_left_int = max(1, round(days_left)) # Ensure it's an integer, minimum 1
result.append(
{
"user": user,
"days_left": days_left_int,
"urgency": "critical"
if days_left <= 1
else "urgent"
if days_left <= 3
else "soon",
}
)
return result