mirror of
https://github.com/wizarrrr/wizarr.git
synced 2026-07-30 23:07:19 -04:00
Increase request timeout for media client API calls to improve reliability closes #995 closes #1014
362 lines
12 KiB
Python
362 lines
12 KiB
Python
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
|