Files
crocodilestick 3e3d2e5e4d feat: Implement auto-send and enhanced auto-metadata fetch systems
## Major Features Added

### 📧 Auto-Send System
- Automatically emails newly ingested books to users' eReaders
- Configurable delay (1-60 minutes) to allow for processing
- Supports multiple formats (EPUB, MOBI, AZW3, KEPUB, PDF)
- Integrates with existing Calibre-Web email configuration
- Respects user preferences and access controls

### 🏷️ Auto-Metadata Fetch System Enhancements
- Enhanced metadata fetching with multiple provider support
- Added smart metadata application mode with intelligent criteria
- Moved control from user-level to admin-only configuration
- Implemented provider hierarchy with drag-and-drop interface
- Added quality-based metadata replacement logic

## Database Schema Changes

### CWA Settings (scripts/cwa_schema.sql)
- Added auto_metadata_smart_application SMALLINT DEFAULT 0
- Enables intelligent vs direct metadata replacement modes

## User Interface Updates

### Admin Interface (cps/templates/cwa_settings.html)
- Added smart metadata application toggle with detailed tooltip
- Enhanced provider hierarchy management

### User Interface (cps/templates/user_edit.html)
- Removed auto_metadata_fetch controls (now admin-only)
- Cleaned up user profile interface

## Smart Metadata Application Logic

### Direct Replacement Mode (Default)
- Takes metadata from preferred provider exactly as provided
- Complete replacement of existing metadata
- Philosophy: "Just take the metadata as is"

### Smart Application Mode (Optional)
- Intelligent criteria for metadata replacement:
  * Titles: Only replace if longer/more descriptive
  * Descriptions: Only replace if longer/more detailed
  * Publishers: Only replace if current field is empty
  * Covers: Only replace if higher resolution
  * Authors: Always update for consistency
  * Tags/Series: Always add for discoverability

## Technical Implementation

### Metadata Helper (cps/metadata_helper.py)
- Enhanced _apply_metadata_to_book() with smart application logic
- Updated fetch_and_apply_metadata() for admin-only control
- Integrated CWA_DB settings checking for both modes

### Ingest Processor (scripts/ingest_processor.py)
- Removed user-based metadata checking
- Streamlined to use admin settings only
- Improved processing pipeline integration

### Form Processing (cps/cwa_functions.py)
- Auto-detection of boolean settings from schema
- Automatic handling of auto_metadata_smart_application

## Provider System Enhancements
- Google Books, Internet Archive, DNB, ComicVine, Douban support
- Priority-based searching with first-success-wins logic
- Quality criteria evaluation for metadata selection
- Configurable provider hierarchy with drag-and-drop interface

## Documentation

### Wiki Pages Created
- Auto-Send-System.md: Comprehensive user and admin guide
- Auto-Metadata-Fetch-System.md: Detailed configuration and usage
- Enhanced with relevant emojis for improved readability
- Covers troubleshooting, best practices, and technical details

## Integration & Compatibility
- Maintains backward compatibility with existing email settings
- Integrates seamlessly with auto-convert and ingest systems
- Respects existing access controls and user permissions
- No breaking changes to existing functionality

## Testing Notes
- Database schema updates will apply automatically on app startup
- Settings form processing handles new boolean field automatically
- Metadata fetching now controlled entirely by admin settings
- User interface cleaned of deprecated metadata controls

This implementation provides a complete automated book delivery and metadata enhancement system while maintaining the principle of admin-controlled automation and user-friendly operation.
2025-09-04 18:22:54 +02:00

873 lines
31 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.
# -*- coding: utf-8 -*-
# Calibre-Web Automated – fork of Calibre-Web
# Copyright (C) 2018-2025 Calibre-Web contributors
# Copyright (C) 2024-2025 Calibre-Web Automated contributors
# SPDX-License-Identifier: GPL-3.0-or-later
# See CONTRIBUTORS for full list of authors.
import atexit
import os
import sys
from datetime import datetime, timezone, timedelta
import itertools
import uuid
from flask import session as flask_session
from binascii import hexlify
from .cw_login import AnonymousUserMixin, current_user
from .cw_login import user_logged_in
try:
from flask_dance.consumer.backend.sqla import OAuthConsumerMixin
oauth_support = True
except ImportError as e:
# fails on flask-dance >1.3, due to renaming
try:
from flask_dance.consumer.storage.sqla import OAuthConsumerMixin
oauth_support = True
except ImportError as e:
OAuthConsumerMixin = BaseException
oauth_support = False
from sqlalchemy import create_engine, exc, exists, event, text
from sqlalchemy import Column, ForeignKey
from sqlalchemy import String, Integer, SmallInteger, Boolean, DateTime, Float, JSON
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.sql.expression import func
try:
# Compatibility with sqlalchemy 2.0
from sqlalchemy.orm import declarative_base
except ImportError:
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import backref, relationship, sessionmaker, Session, scoped_session
from werkzeug.security import generate_password_hash
from . import constants, logger
from .string_helper import strip_whitespaces
log = logger.create()
session = None
app_DB_path = None
Base = declarative_base()
searched_ids = {}
logged_in = dict()
def signal_store_user_session(object, user):
store_user_session()
def store_user_session():
_user = flask_session.get('_user_id', "")
_id = flask_session.get('_id', "")
_random = flask_session.get('_random', "")
if flask_session.get('_user_id', ""):
try:
if not check_user_session(_user, _id, _random):
expiry = int((datetime.now() + timedelta(days=31)).timestamp())
user_session = User_Sessions(_user, _id, _random, expiry)
session.add(user_session)
session.commit()
log.debug("Login and store session : " + _id)
else:
log.debug("Found stored session: " + _id)
except (exc.OperationalError, exc.InvalidRequestError) as e:
session.rollback()
log.exception(e)
else:
log.error("No user id in session")
def delete_user_session(user_id, session_key):
try:
log.debug("Deleted session_key: " + session_key)
session.query(User_Sessions).filter(User_Sessions.user_id == user_id,
User_Sessions.session_key == session_key).delete()
session.commit()
except (exc.OperationalError, exc.InvalidRequestError) as ex:
session.rollback()
log.exception(ex)
def check_user_session(user_id, session_key, random):
try:
found = session.query(User_Sessions).filter(User_Sessions.user_id==user_id,
User_Sessions.session_key==session_key,
User_Sessions.random == random,
).one_or_none()
if found is not None:
new_expiry = int((datetime.now() + timedelta(days=31)).timestamp())
if new_expiry - found.expiry > 86400:
found.expiry = new_expiry
session.merge(found)
session.commit()
return bool(found)
except (exc.OperationalError, exc.InvalidRequestError) as e:
session.rollback()
log.exception(e)
return False
user_logged_in.connect(signal_store_user_session)
def store_ids(result):
ids = list()
for element in result:
ids.append(element.id)
searched_ids[current_user.id] = ids
def store_combo_ids(result):
ids = list()
for element in result:
ids.append(element[0].id)
searched_ids[current_user.id] = ids
class UserBase:
@property
def is_authenticated(self):
return self.is_active
def _has_role(self, role_flag):
return constants.has_flag(self.role, role_flag)
def role_admin(self):
return self._has_role(constants.ROLE_ADMIN)
def role_download(self):
return self._has_role(constants.ROLE_DOWNLOAD)
def role_upload(self):
return self._has_role(constants.ROLE_UPLOAD)
def role_edit(self):
return self._has_role(constants.ROLE_EDIT)
def role_passwd(self):
return self._has_role(constants.ROLE_PASSWD)
def role_anonymous(self):
return self._has_role(constants.ROLE_ANONYMOUS)
def role_edit_shelfs(self):
return self._has_role(constants.ROLE_EDIT_SHELFS)
def role_delete_books(self):
return self._has_role(constants.ROLE_DELETE_BOOKS)
def role_viewer(self):
return self._has_role(constants.ROLE_VIEWER)
@property
def is_active(self):
return True
@property
def is_anonymous(self):
return self.role_anonymous()
def get_id(self):
return str(self.id)
def filter_language(self):
return self.default_language
def check_visibility(self, value):
if value == constants.SIDEBAR_RECENT:
return True
return constants.has_flag(self.sidebar_view, value)
def show_detail_random(self):
return self.check_visibility(constants.DETAIL_RANDOM)
def list_denied_tags(self):
mct = self.denied_tags or ""
return [strip_whitespaces(t) for t in mct.split(",")]
def list_allowed_tags(self):
mct = self.allowed_tags or ""
return [strip_whitespaces(t) for t in mct.split(",")]
def list_denied_column_values(self):
mct = self.denied_column_value or ""
return [strip_whitespaces(t) for t in mct.split(",")]
def list_allowed_column_values(self):
mct = self.allowed_column_value or ""
return [strip_whitespaces(t) for t in mct.split(",")]
def get_view_property(self, page, prop):
if not self.view_settings.get(page):
return None
return self.view_settings[page].get(prop)
def set_view_property(self, page, prop, value):
if not self.view_settings.get(page):
self.view_settings[page] = dict()
self.view_settings[page][prop] = value
try:
flag_modified(self, "view_settings")
except AttributeError:
pass
try:
session.commit()
except (exc.OperationalError, exc.InvalidRequestError) as e:
session.rollback()
log.error_or_exception(e)
def __repr__(self):
return '<User %r>' % self.name
# Baseclass for Users in Calibre-Web, settings which are depending on certain users are stored here. It is derived from
# User Base (all access methods are declared there)
class User(UserBase, Base):
__tablename__ = 'user'
__table_args__ = {'sqlite_autoincrement': True}
id = Column(Integer, primary_key=True)
name = Column(String(64), unique=True)
email = Column(String(120), unique=True, default="")
role = Column(SmallInteger, default=constants.ROLE_USER)
password = Column(String)
kindle_mail = Column(String(120), default="")
shelf = relationship('Shelf', backref='user', lazy='dynamic', order_by='Shelf.name')
downloads = relationship('Downloads', backref='user', lazy='dynamic')
locale = Column(String(2), default="en")
sidebar_view = Column(Integer, default=1)
default_language = Column(String(3), default="all")
denied_tags = Column(String, default="")
allowed_tags = Column(String, default="")
denied_column_value = Column(String, default="")
allowed_column_value = Column(String, default="")
remote_auth_token = relationship('RemoteAuthToken', backref='user', lazy='dynamic')
view_settings = Column(JSON, default={})
kobo_only_shelves_sync = Column(Integer, default=0)
hardcover_token = Column(String, unique=True, default=None)
# New per-user theme (0=default/light, 1=caliBlur) replacing global-only behavior
theme = Column(Integer, default=0)
# Auto-send settings for new books
auto_send_enabled = Column(Boolean, default=False)
if oauth_support:
class OAuth(OAuthConsumerMixin, Base):
provider_user_id = Column(String(256))
user_id = Column(Integer, ForeignKey(User.id))
user = relationship(User)
class OAuthProvider(Base):
__tablename__ = 'oauthProvider'
id = Column(Integer, primary_key=True)
provider_name = Column(String)
oauth_client_id = Column(String)
oauth_client_secret = Column(String)
oauth_base_url = Column(String, default=None)
oauth_authorize_url = Column(String, default=None)
oauth_token_url = Column(String, default=None)
oauth_userinfo_url = Column(String, default=None)
oauth_admin_group = Column(String, default=None)
metadata_url = Column(String, default=None) # For OIDC auto-discovery
scope = Column(String, default="openid profile email") # Customizable OAuth scopes
username_mapper = Column(String, default="preferred_username") # JWT field for username
email_mapper = Column(String, default="email") # JWT field for email
login_button = Column(String, default="OpenID Connect") # Custom button text
active = Column(Boolean)
# Class for anonymous user is derived from User base and completely overrides methods and properties for the
# anonymous user
class Anonymous(AnonymousUserMixin, UserBase):
def __init__(self):
self.hardcover_token = None
self.kobo_only_shelves_sync = None
self.view_settings = None
self.allowed_column_value = None
self.allowed_tags = None
self.denied_tags = None
self.kindle_mail = None
self.locale = None
self.default_language = None
self.sidebar_view = None
self.id = None
self.role = None
self.name = None
self.auto_send_enabled = False
self.loadSettings()
def loadSettings(self):
data = session.query(User).filter(User.role.op('&')(constants.ROLE_ANONYMOUS) == constants.ROLE_ANONYMOUS)\
.first() # type: User
self.name = data.name
self.role = data.role
self.id=data.id
self.sidebar_view = data.sidebar_view
self.default_language = data.default_language
self.locale = data.locale
self.kindle_mail = data.kindle_mail
self.denied_tags = data.denied_tags
self.allowed_tags = data.allowed_tags
self.denied_column_value = data.denied_column_value
self.allowed_column_value = data.allowed_column_value
self.view_settings = data.view_settings
self.kobo_only_shelves_sync = data.kobo_only_shelves_sync
self.hardcover_token = data.hardcover_token
self.auto_send_enabled = data.auto_send_enabled
def role_admin(self):
return False
@property
def is_active(self):
return False
@property
def is_anonymous(self):
return True
@property
def is_authenticated(self):
return False
def get_view_property(self, page, prop):
if 'view' in flask_session:
if not flask_session['view'].get(page):
return None
return flask_session['view'][page].get(prop)
return None
def set_view_property(self, page, prop, value):
if not 'view' in flask_session:
flask_session['view'] = dict()
if not flask_session['view'].get(page):
flask_session['view'][page] = dict()
flask_session['view'][page][prop] = value
class User_Sessions(Base):
__tablename__ = 'user_session'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('user.id'))
session_key = Column(String, default="")
random = Column(String, default="")
expiry = Column(Integer)
def __init__(self, user_id, session_key, random, expiry):
super().__init__()
self.user_id = user_id
self.session_key = session_key
self.random = random
self.expiry = expiry
# Baseclass representing Shelfs in calibre-web in app.db
class Shelf(Base):
__tablename__ = 'shelf'
id = Column(Integer, primary_key=True)
uuid = Column(String, default=lambda: str(uuid.uuid4()))
name = Column(String)
is_public = Column(Integer, default=0)
user_id = Column(Integer, ForeignKey('user.id'))
kobo_sync = Column(Boolean, default=False)
books = relationship("BookShelf", backref="ub_shelf", cascade="all, delete-orphan", lazy="dynamic")
created = Column(DateTime, default=lambda: datetime.now(timezone.utc))
last_modified = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
def __repr__(self):
return '<Shelf %d:%r>' % (self.id, self.name)
# Baseclass representing Relationship between books and Shelfs in Calibre-Web in app.db (N:M)
class BookShelf(Base):
__tablename__ = 'book_shelf_link'
id = Column(Integer, primary_key=True)
book_id = Column(Integer)
order = Column(Integer)
shelf = Column(Integer, ForeignKey('shelf.id'))
date_added = Column(DateTime, default=lambda: datetime.now(timezone.utc))
def __repr__(self):
return '<Book %r>' % self.id
# This table keeps track of deleted Shelves so that deletes can be propagated to any paired Kobo device.
class ShelfArchive(Base):
__tablename__ = 'shelf_archive'
id = Column(Integer, primary_key=True)
uuid = Column(String)
user_id = Column(Integer, ForeignKey('user.id'))
last_modified = Column(DateTime, default=lambda: datetime.now(timezone.utc))
class ReadBook(Base):
__tablename__ = 'book_read_link'
STATUS_UNREAD = 0
STATUS_FINISHED = 1
STATUS_IN_PROGRESS = 2
id = Column(Integer, primary_key=True)
book_id = Column(Integer, unique=False)
user_id = Column(Integer, ForeignKey('user.id'), unique=False)
read_status = Column(Integer, unique=False, default=STATUS_UNREAD, nullable=False)
kobo_reading_state = relationship("KoboReadingState", uselist=False,
primaryjoin="and_(ReadBook.user_id == foreign(KoboReadingState.user_id), "
"ReadBook.book_id == foreign(KoboReadingState.book_id))",
cascade="all",
backref=backref("book_read_link",
uselist=False))
last_modified = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
last_time_started_reading = Column(DateTime, nullable=True)
times_started_reading = Column(Integer, default=0, nullable=False)
class Bookmark(Base):
__tablename__ = 'bookmark'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('user.id'))
book_id = Column(Integer)
format = Column(String(collation='NOCASE'))
bookmark_key = Column(String)
# Baseclass representing books that are archived on the user's Kobo device.
class ArchivedBook(Base):
__tablename__ = 'archived_book'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('user.id'))
book_id = Column(Integer)
is_archived = Column(Boolean, unique=False)
last_modified = Column(DateTime, default=lambda: datetime.now(timezone.utc))
class KoboSyncedBooks(Base):
__tablename__ = 'kobo_synced_books'
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey('user.id'))
book_id = Column(Integer)
# The Kobo ReadingState API keeps track of 4 timestamped entities:
# ReadingState, StatusInfo, Statistics, CurrentBookmark
# Which we map to the following 4 tables:
# KoboReadingState, ReadBook, KoboStatistics and KoboBookmark
class KoboReadingState(Base):
__tablename__ = 'kobo_reading_state'
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey('user.id'))
book_id = Column(Integer)
last_modified = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
priority_timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
current_bookmark = relationship("KoboBookmark", uselist=False, backref="kobo_reading_state", cascade="all, delete")
statistics = relationship("KoboStatistics", uselist=False, backref="kobo_reading_state", cascade="all, delete")
class KoboBookmark(Base):
__tablename__ = 'kobo_bookmark'
id = Column(Integer, primary_key=True)
kobo_reading_state_id = Column(Integer, ForeignKey('kobo_reading_state.id'))
last_modified = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
location_source = Column(String)
location_type = Column(String)
location_value = Column(String)
progress_percent = Column(Float)
content_source_progress_percent = Column(Float)
class KoboStatistics(Base):
__tablename__ = 'kobo_statistics'
id = Column(Integer, primary_key=True)
kobo_reading_state_id = Column(Integer, ForeignKey('kobo_reading_state.id'))
last_modified = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
remaining_time_minutes = Column(Integer)
spent_reading_minutes = Column(Integer)
# Updates the last_modified timestamp in the KoboReadingState table if any of its children tables are modified.
@event.listens_for(Session, 'before_flush')
def receive_before_flush(session, flush_context, instances):
for change in itertools.chain(session.new, session.dirty):
if isinstance(change, (ReadBook, KoboStatistics, KoboBookmark)):
if change.kobo_reading_state:
change.kobo_reading_state.last_modified = datetime.now(timezone.utc)
# Maintain the last_modified_bit for the Shelf table.
for change in itertools.chain(session.new, session.deleted):
if isinstance(change, BookShelf):
change.ub_shelf.last_modified = datetime.now(timezone.utc)
# Baseclass representing Downloads from calibre-web in app.db
class Downloads(Base):
__tablename__ = 'downloads'
id = Column(Integer, primary_key=True)
book_id = Column(Integer)
user_id = Column(Integer, ForeignKey('user.id'))
def __repr__(self):
return '<Download %r' % self.book_id
# Baseclass representing KOReader sync progress
class KOSyncProgress(Base):
__tablename__ = 'kosync_progress'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('user.id'), nullable=False)
document = Column(String, nullable=False)
progress = Column(String, nullable=False)
percentage = Column(Float, nullable=False)
device = Column(String, nullable=False)
device_id = Column(String)
timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
def __repr__(self):
return '<KOSyncProgress %r - %r>' % (self.user_id, self.document)
# Baseclass representing allowed domains for registration
class Registration(Base):
__tablename__ = 'registration'
id = Column(Integer, primary_key=True)
domain = Column(String)
allow = Column(Integer)
def __repr__(self):
return "<Registration('{0}')>".format(self.domain)
class RemoteAuthToken(Base):
__tablename__ = 'remote_auth_token'
id = Column(Integer, primary_key=True)
auth_token = Column(String, unique=True)
user_id = Column(Integer, ForeignKey('user.id'))
verified = Column(Boolean, default=False)
expiration = Column(DateTime)
token_type = Column(Integer, default=0)
def __init__(self):
super().__init__()
self.auth_token = (hexlify(os.urandom(4))).decode('utf-8')
self.expiration = datetime.now() + timedelta(minutes=10) # 10 min from now
def __repr__(self):
return '<Token %r>' % self.id
def filename(context):
file_format = context.get_current_parameters()['format']
if file_format == 'jpeg':
return context.get_current_parameters()['uuid'] + '.jpg'
else:
return context.get_current_parameters()['uuid'] + '.' + file_format
class Thumbnail(Base):
__tablename__ = 'thumbnail'
id = Column(Integer, primary_key=True)
entity_id = Column(Integer)
uuid = Column(String, default=lambda: str(uuid.uuid4()), unique=True)
format = Column(String, default='jpeg')
type = Column(SmallInteger, default=constants.THUMBNAIL_TYPE_COVER)
resolution = Column(SmallInteger, default=constants.COVER_THUMBNAIL_SMALL)
filename = Column(String, default=filename)
generated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
expiration = Column(DateTime, nullable=True)
# Add missing tables during migration of database
def add_missing_tables(engine, _session):
if not engine.dialect.has_table(engine.connect(), "archived_book"):
ArchivedBook.__table__.create(bind=engine)
if not engine.dialect.has_table(engine.connect(), "thumbnail"):
Thumbnail.__table__.create(bind=engine)
if not engine.dialect.has_table(engine.connect(), "kosync_progress"):
KOSyncProgress.__table__.create(bind=engine)
# migrate all settings missing in registration table
def migrate_registration_table(engine, _session):
try:
# Handle table exists, but no content
cnt = _session.query(Registration).count()
if not cnt:
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("insert into registration (domain, allow) values('%.%',1)"))
trans.commit()
except exc.OperationalError: # Database is not writeable
print('Settings database is not writeable. Exiting...')
sys.exit(2)
def migrate_user_session_table(engine, _session):
try:
_session.query(exists().where(User_Sessions.random)).scalar()
_session.commit()
except exc.OperationalError: # Database is not compatible, some columns are missing
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("ALTER TABLE user_session ADD column 'random' String"))
conn.execute(text("ALTER TABLE user_session ADD column 'expiry' Integer"))
trans.commit()
def migrate_user_table(engine, _session):
try:
_session.query(exists().where(User.hardcover_token)).scalar()
_session.commit()
except exc.OperationalError: # Database is not compatible, some columns are missing
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("ALTER TABLE user ADD column 'hardcover_token' String"))
trans.commit()
# Migration for per-user theme column
try:
_session.query(exists().where(User.theme)).scalar()
_session.commit()
except exc.OperationalError:
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("ALTER TABLE user ADD column 'theme' Integer DEFAULT 0"))
trans.commit()
# Migration for auto-send feature columns
try:
_session.query(exists().where(User.auto_send_enabled)).scalar()
_session.commit()
except exc.OperationalError:
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("ALTER TABLE user ADD column 'auto_send_enabled' Boolean DEFAULT 0"))
trans.commit()
# Migration to enable duplicates sidebar for existing admin users
try:
from . import constants
SIDEBAR_DUPLICATES = constants.SIDEBAR_DUPLICATES
# Check if any admin users don't have duplicates enabled
admin_users = _session.query(User).filter(User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN).all()
for user in admin_users:
if not (user.sidebar_view & SIDEBAR_DUPLICATES):
user.sidebar_view |= SIDEBAR_DUPLICATES
print(f"[Migration] Enabled duplicates sidebar for admin user: {user.name}")
_session.commit()
except Exception as e:
print(f"[Migration] Warning: Could not update duplicates sidebar setting: {e}")
_session.rollback()
def migrate_oauth_provider_table(engine, _session):
try:
_session.query(exists().where(OAuthProvider.oauth_base_url)).scalar()
_session.commit()
except exc.OperationalError: # Database is not compatible, some columns are missing
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("ALTER TABLE oauthProvider ADD column 'oauth_base_url' String DEFAULT NULL"))
conn.execute(text("ALTER TABLE oauthProvider ADD column 'oauth_authorize_url' String DEFAULT NULL"))
conn.execute(text("ALTER TABLE oauthProvider ADD column 'oauth_token_url' String DEFAULT NULL"))
conn.execute(text("ALTER TABLE oauthProvider ADD column 'oauth_userinfo_url' String DEFAULT NULL"))
conn.execute(text("ALTER TABLE oauthProvider ADD column 'oauth_admin_group' String DEFAULT NULL"))
trans.commit()
# Add new OAuth enhancement fields
try:
_session.query(exists().where(OAuthProvider.metadata_url)).scalar()
_session.commit()
except exc.OperationalError: # New columns are missing
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("ALTER TABLE oauthProvider ADD column 'metadata_url' String DEFAULT NULL"))
conn.execute(text("ALTER TABLE oauthProvider ADD column 'scope' String DEFAULT 'openid profile email'"))
conn.execute(text("ALTER TABLE oauthProvider ADD column 'username_mapper' String DEFAULT 'preferred_username'"))
conn.execute(text("ALTER TABLE oauthProvider ADD column 'email_mapper' String DEFAULT 'email'"))
conn.execute(text("ALTER TABLE oauthProvider ADD column 'login_button' String DEFAULT 'OpenID Connect'"))
trans.commit()
# Migrate database to current version, has to be updated after every database change. Currently migration from
# maybe 4/5 versions back to current should work.
# Migration is done by checking if relevant columns are existing, and then adding rows with SQL commands
def migrate_Database(_session):
engine = _session.bind
add_missing_tables(engine, _session)
migrate_registration_table(engine, _session)
migrate_user_session_table(engine, _session)
migrate_user_table(engine, _session)
migrate_oauth_provider_table(engine, _session)
def clean_database(_session):
# Remove expired remote login tokens
now = datetime.now()
try:
_session.query(RemoteAuthToken).filter(now > RemoteAuthToken.expiration).\
filter(RemoteAuthToken.token_type != 1).delete()
_session.commit()
except exc.OperationalError: # Database is not writeable
print('Settings database is not writeable. Exiting...')
sys.exit(2)
# Save downloaded books per user in calibre-web's own database
def update_download(book_id, user_id):
check = session.query(Downloads).filter(Downloads.user_id == user_id).filter(Downloads.book_id == book_id).first()
if not check:
new_download = Downloads(user_id=user_id, book_id=book_id)
session.add(new_download)
try:
session.commit()
except exc.OperationalError:
session.rollback()
# Delete non existing downloaded books in calibre-web's own database
def delete_download(book_id):
session.query(Downloads).filter(book_id == Downloads.book_id).delete()
try:
session.commit()
except exc.OperationalError:
session.rollback()
# Generate user Guest (translated text), as anonymous user, no rights
def create_anonymous_user(_session):
user = User()
user.name = "Guest"
user.email = 'no@email'
user.role = constants.ROLE_ANONYMOUS
user.password = ''
_session.add(user)
try:
_session.commit()
except Exception:
_session.rollback()
# Generate User admin with admin123 password, and access to everything
def create_admin_user(_session):
user = User()
user.name = "admin"
user.email = "admin@example.org"
user.role = constants.ADMIN_USER_ROLES
user.sidebar_view = constants.ADMIN_USER_SIDEBAR
user.password = generate_password_hash(constants.DEFAULT_PASSWORD)
_session.add(user)
try:
_session.commit()
except Exception:
_session.rollback()
def init_db_thread():
global app_DB_path
engine = create_engine('sqlite:///{0}'.format(app_DB_path), echo=False,
connect_args={'timeout': 30})
Session = scoped_session(sessionmaker())
Session.configure(bind=engine)
return Session()
def init_db(app_db_path):
# Open session for database connection
global session
global app_DB_path
app_DB_path = app_db_path
engine = create_engine('sqlite:///{0}'.format(app_db_path), echo=False,
connect_args={'timeout': 30})
Session = scoped_session(sessionmaker())
Session.configure(bind=engine)
session = Session()
if os.path.exists(app_db_path):
Base.metadata.create_all(engine)
migrate_Database(session)
clean_database(session)
else:
Base.metadata.create_all(engine)
create_admin_user(session)
create_anonymous_user(session)
def password_change(user_credentials=None):
if user_credentials:
username, password = user_credentials.split(':', 1)
user = session.query(User).filter(func.lower(User.name) == username.lower()).first()
if user:
if not password:
print("Empty password is not allowed")
sys.exit(4)
try:
from .helper import valid_password
user.password = generate_password_hash(valid_password(password))
except Exception:
print("Password doesn't comply with password validation rules")
sys.exit(4)
if session_commit() == "":
print("Password for user '{}' changed".format(username))
sys.exit(0)
else:
print("Failed changing password")
sys.exit(3)
else:
print("Username '{}' not valid, can't change password".format(username))
sys.exit(3)
def get_new_session_instance():
new_engine = create_engine('sqlite:///{0}'.format(app_DB_path), echo=False,
connect_args={'timeout': 30})
new_session = scoped_session(sessionmaker())
new_session.configure(bind=new_engine)
atexit.register(lambda: new_session.remove() if new_session else True)
return new_session
def dispose():
global session
old_session = session
session = None
if old_session:
try:
old_session.close()
except Exception:
pass
if old_session.bind:
try:
old_session.bind.dispose()
except Exception:
pass
def session_commit(success=None, _session=None):
s = _session if _session else session
try:
s.commit()
if success:
log.info(success)
except (exc.OperationalError, exc.InvalidRequestError) as e:
s.rollback()
log.error_or_exception(e)
return ""