From 46205a1f83ec6a81c57d5dd3348ea583f9201063 Mon Sep 17 00:00:00 2001 From: mmonkey Date: Wed, 29 Sep 2021 02:40:12 -0500 Subject: [PATCH] Made long running tasks cancellable. Added cancel button to cancellable tasks in the task list. Added APP_MODE env variable for determining if the app is running in development, test, or production. --- cps.py | 8 +- cps/admin.py | 23 +++++- cps/constants.py | 7 +- cps/db.py | 6 +- cps/helper.py | 17 +++- cps/schedule.py | 61 ++++++++++++--- cps/services/background_scheduler.py | 31 ++++++-- cps/services/worker.py | 35 +++++++-- cps/static/css/caliBlur.css | 85 ++++++++------------ cps/static/js/main.js | 12 --- cps/static/js/table.js | 34 +++++++- cps/tasks/convert.py | 4 + cps/tasks/database.py | 4 + cps/tasks/mail.py | 6 +- cps/tasks/thumbnail.py | 111 +++++++++++++++++++-------- cps/tasks/upload.py | 4 + cps/templates/admin.html | 2 +- cps/templates/schedule_edit.html | 6 +- cps/templates/tasks.html | 27 +++++++ cps/web.py | 4 +- 20 files changed, 340 insertions(+), 147 deletions(-) diff --git a/cps.py b/cps.py index fe006551..20a27c71 100755 --- a/cps.py +++ b/cps.py @@ -44,7 +44,7 @@ from cps.editbooks import editbook from cps.remotelogin import remotelogin from cps.search_metadata import meta from cps.error_handler import init_errorhandler -from cps.schedule import register_jobs, register_startup_jobs +from cps.schedule import register_scheduled_tasks, register_startup_tasks try: from cps.kobo import kobo, get_kobo_activated @@ -81,9 +81,9 @@ def main(): if oauth_available: app.register_blueprint(oauth) - # Register scheduled jobs - register_jobs() - # register_startup_jobs() + # Register scheduled tasks + register_scheduled_tasks() + register_startup_tasks() success = web_server.start() sys.exit(0 if success else 1) diff --git a/cps/admin.py b/cps/admin.py index bd292ba3..62b3dbe0 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -40,12 +40,13 @@ from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError from sqlalchemy.sql.expression import func, or_, text -from . import constants, logger, helper, services, isoLanguages, fs -from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils +from . import constants, logger, helper, services, isoLanguages +from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils, schedule from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \ valid_email, check_username from .gdriveutils import is_gdrive_ready, gdrive_support from .render_template import render_title_template, get_sidebar_config +from .services.worker import WorkerThread from . import debug_info, _BABEL_TRANSLATIONS try: @@ -1568,7 +1569,7 @@ def update_mailsettings(): @admin_required def edit_scheduledtasks(): content = config.get_scheduled_task_settings() - return render_title_template("schedule_edit.html", content=content, title=_(u"Edit Scheduled Tasks Settings")) + return render_title_template("schedule_edit.html", config=content, title=_(u"Edit Scheduled Tasks Settings")) @admi.route("/admin/scheduledtasks", methods=["POST"]) @@ -1584,6 +1585,12 @@ def update_scheduledtasks(): try: config.save() flash(_(u"Scheduled tasks settings updated"), category="success") + + # Cancel any running tasks + schedule.end_scheduled_tasks() + + # Re-register tasks with new settings + schedule.register_scheduled_tasks() except IntegrityError as ex: ub.session.rollback() log.error("An unknown error occurred while saving scheduled tasks settings") @@ -1869,3 +1876,13 @@ def extract_dynamic_field_from_filter(user, filtr): def extract_user_identifier(user, filtr): dynamic_field = extract_dynamic_field_from_filter(user, filtr) return extract_user_data_from_field(user, dynamic_field) + + +@admi.route("/ajax/canceltask", methods=['POST']) +@login_required +@admin_required +def cancel_task(): + task_id = request.get_json().get('task_id', None) + worker = WorkerThread.get_instance() + worker.end_task(task_id) + return "" diff --git a/cps/constants.py b/cps/constants.py index 306d2872..a92a0029 100644 --- a/cps/constants.py +++ b/cps/constants.py @@ -24,10 +24,13 @@ from sqlalchemy import __version__ as sql_version sqlalchemy_version2 = ([int(x) for x in sql_version.split('.')] >= [2,0,0]) +# APP_MODE - production, development, or test +APP_MODE = os.environ.get('APP_MODE', 'production') + # if installed via pip this variable is set to true (empty file with name .HOMEDIR present) HOME_CONFIG = os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(__file__)), '.HOMEDIR')) -#In executables updater is not available, so variable is set to False there +# In executables updater is not available, so variable is set to False there UPDATER_AVAILABLE = True # Base dir is parent of current file, necessary if called from different folder @@ -43,7 +46,7 @@ TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations') # Cache dir - use CACHE_DIR environment variable, otherwise use the default directory: cps/cache DEFAULT_CACHE_DIR = os.path.join(BASE_DIR, 'cps', 'cache') -CACHE_DIR = os.environ.get("CACHE_DIR", DEFAULT_CACHE_DIR) +CACHE_DIR = os.environ.get('CACHE_DIR', DEFAULT_CACHE_DIR) if HOME_CONFIG: home_dir = os.path.join(os.path.expanduser("~"),".calibre-web") diff --git a/cps/db.py b/cps/db.py index 296db7a4..e4cea100 100644 --- a/cps/db.py +++ b/cps/db.py @@ -443,11 +443,11 @@ class CalibreDB(): """ self.session = None if self._init: - self.initSession(expire_on_commit) + self.init_session(expire_on_commit) self.instances.add(self) - def initSession(self, expire_on_commit=True): + def init_session(self, expire_on_commit=True): self.session = self.session_factory() self.session.expire_on_commit = expire_on_commit self.update_title_sort(self.config) @@ -593,7 +593,7 @@ class CalibreDB(): autoflush=True, bind=cls.engine)) for inst in cls.instances: - inst.initSession() + inst.init_session() cls._init = True return True diff --git a/cps/helper.py b/cps/helper.py index 2f2df0e0..a221dbb7 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -57,7 +57,8 @@ from . import logger, config, get_locale, db, fs, ub from . import gdriveutils as gd from .constants import STATIC_DIR as _STATIC_DIR, CACHE_TYPE_THUMBNAILS, THUMBNAIL_TYPE_COVER, THUMBNAIL_TYPE_SERIES from .subproc_wrapper import process_wait -from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS +from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS, STAT_ENDED, \ + STAT_CANCELLED from .tasks.mail import TaskEmail from .tasks.thumbnail import TaskClearCoverThumbnailCache @@ -838,12 +839,22 @@ def render_task_status(tasklist): ret['status'] = _(u'Started') elif task.stat == STAT_FINISH_SUCCESS: ret['status'] = _(u'Finished') + elif task.stat == STAT_ENDED: + ret['status'] = _(u'Ended') + elif task.stat == STAT_CANCELLED: + ret['status'] = _(u'Cancelled') else: ret['status'] = _(u'Unknown Status') - ret['taskMessage'] = "{}: {}".format(_(task.name), task.message) + ret['taskMessage'] = "{}: {}".format(_(task.name), task.message) if task.message else _(task.name) ret['progress'] = "{} %".format(int(task.progress * 100)) ret['user'] = escape(user) # prevent xss + + # Hidden fields + ret['id'] = task.id + ret['stat'] = task.stat + ret['is_cancellable'] = task.is_cancellable + renderedtasklist.append(ret) return renderedtasklist @@ -914,5 +925,5 @@ def get_download_link(book_id, book_format, client): abort(404) -def clear_cover_thumbnail_cache(book_id=None): +def clear_cover_thumbnail_cache(book_id): WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id)) diff --git a/cps/schedule.py b/cps/schedule.py index 2cddaecb..2bb7878f 100644 --- a/cps/schedule.py +++ b/cps/schedule.py @@ -17,29 +17,70 @@ # along with this program. If not, see . from __future__ import division, print_function, unicode_literals +import datetime +from . import config, constants from .services.background_scheduler import BackgroundScheduler from .tasks.database import TaskReconnectDatabase from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails +from .services.worker import WorkerThread -def register_jobs(): +def get_scheduled_tasks(reconnect=True): + tasks = list() + + # Reconnect Calibre database (metadata.db) + if reconnect: + tasks.append(lambda: TaskReconnectDatabase()) + + # Generate all missing book cover thumbnails + if config.schedule_generate_book_covers: + tasks.append(lambda: TaskGenerateCoverThumbnails()) + + # Generate all missing series thumbnails + if config.schedule_generate_series_covers: + tasks.append(lambda: TaskGenerateSeriesThumbnails()) + + return tasks + + +def end_scheduled_tasks(): + worker = WorkerThread.get_instance() + for __, __, __, task in worker.tasks: + if task.scheduled and task.is_cancellable: + worker.end_task(task.id) + + +def register_scheduled_tasks(): scheduler = BackgroundScheduler() if scheduler: - # Reconnect Calibre database (metadata.db) - scheduler.schedule_task(user=None, task=lambda: TaskReconnectDatabase(), trigger='cron', hour='4,16') + # Remove all existing jobs + scheduler.remove_all_jobs() - # Generate all missing book cover thumbnails - scheduler.schedule_task(user=None, task=lambda: TaskGenerateCoverThumbnails(), trigger='cron', hour=4) + start = config.schedule_start_time + end = config.schedule_end_time - # Generate all missing series thumbnails - scheduler.schedule_task(user=None, task=lambda: TaskGenerateSeriesThumbnails(), trigger='cron', hour=4) + # Register scheduled tasks + if start != end: + scheduler.schedule_tasks(tasks=get_scheduled_tasks(), trigger='cron', hour=start) + scheduler.schedule(func=end_scheduled_tasks, trigger='cron', hour=end) + # Kick-off tasks, if they should currently be running + now = datetime.datetime.now().hour + if start <= now < end: + scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False)) -def register_startup_jobs(): + +def register_startup_tasks(): scheduler = BackgroundScheduler() if scheduler: - scheduler.schedule_task_immediately(None, task=lambda: TaskGenerateCoverThumbnails()) - scheduler.schedule_task_immediately(None, task=lambda: TaskGenerateSeriesThumbnails()) + start = config.schedule_start_time + end = config.schedule_end_time + now = datetime.datetime.now().hour + + # Run scheduled tasks immediately for development and testing + # Ignore tasks that should currently be running, as these will be added when registering scheduled tasks + if constants.APP_MODE in ['development', 'test'] and not (start <= now < end): + scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False)) diff --git a/cps/services/background_scheduler.py b/cps/services/background_scheduler.py index ba578903..971b0bf7 100644 --- a/cps/services/background_scheduler.py +++ b/cps/services/background_scheduler.py @@ -48,23 +48,38 @@ class BackgroundScheduler: return cls._instance - def _add(self, func, trigger, **trigger_args): + def schedule(self, func, trigger, **trigger_args): if use_APScheduler: return self.scheduler.add_job(func=func, trigger=trigger, **trigger_args) - # Expects a lambda expression for the task, so that the task isn't instantiated before the task is scheduled - def schedule_task(self, user, task, trigger, **trigger_args): + # Expects a lambda expression for the task + def schedule_task(self, task, user=None, trigger='cron', **trigger_args): if use_APScheduler: def scheduled_task(): worker_task = task() + worker_task.scheduled = True WorkerThread.add(user, worker_task) + return self.schedule(func=scheduled_task, trigger=trigger, **trigger_args) - return self._add(func=scheduled_task, trigger=trigger, **trigger_args) + # Expects a list of lambda expressions for the tasks + def schedule_tasks(self, tasks, user=None, trigger='cron', **trigger_args): + if use_APScheduler: + for task in tasks: + self.schedule_task(task, user=user, trigger=trigger, **trigger_args) - # Expects a lambda expression for the task, so that the task isn't instantiated before the task is scheduled - def schedule_task_immediately(self, user, task): + # Expects a lambda expression for the task + def schedule_task_immediately(self, task, user=None): if use_APScheduler: - def scheduled_task(): + def immediate_task(): WorkerThread.add(user, task()) + return self.schedule(func=immediate_task, trigger='date') + + # Expects a list of lambda expressions for the tasks + def schedule_tasks_immediately(self, tasks, user=None): + if use_APScheduler: + for task in tasks: + self.schedule_task_immediately(task, user) - return self._add(func=scheduled_task, trigger='date') + # Remove all jobs + def remove_all_jobs(self): + self.scheduler.remove_all_jobs() diff --git a/cps/services/worker.py b/cps/services/worker.py index 97068f74..04a4c056 100644 --- a/cps/services/worker.py +++ b/cps/services/worker.py @@ -21,6 +21,8 @@ STAT_WAITING = 0 STAT_FAIL = 1 STAT_STARTED = 2 STAT_FINISH_SUCCESS = 3 +STAT_ENDED = 4 +STAT_CANCELLED = 5 # Only retain this many tasks in dequeued list TASK_CLEANUP_TRIGGER = 20 @@ -50,7 +52,7 @@ class WorkerThread(threading.Thread): _instance = None @classmethod - def getInstance(cls): + def get_instance(cls): if cls._instance is None: cls._instance = WorkerThread() return cls._instance @@ -67,12 +69,13 @@ class WorkerThread(threading.Thread): @classmethod def add(cls, user, task): - ins = cls.getInstance() + ins = cls.get_instance() ins.num += 1 - log.debug("Add Task for user: {}: {}".format(user, task)) + username = user if user is not None else 'System' + log.debug("Add Task for user: {}: {}".format(username, task)) ins.queue.put(QueuedTask( num=ins.num, - user=user, + user=username, added=datetime.now(), task=task, )) @@ -134,6 +137,12 @@ class WorkerThread(threading.Thread): self.queue.task_done() + def end_task(self, task_id): + ins = self.get_instance() + for __, __, __, task in ins.tasks: + if str(task.id) == str(task_id) and task.is_cancellable: + task.stat = STAT_CANCELLED if task.stat == STAT_WAITING else STAT_ENDED + class CalibreTask: __metaclass__ = abc.ABCMeta @@ -147,10 +156,11 @@ class CalibreTask: self.message = message self.id = uuid.uuid4() self.self_cleanup = False + self._scheduled = False @abc.abstractmethod def run(self, worker_thread): - """Provides the caller some human-readable name for this class""" + """The main entry-point for this task""" raise NotImplementedError @abc.abstractmethod @@ -158,6 +168,11 @@ class CalibreTask: """Provides the caller some human-readable name for this class""" raise NotImplementedError + @abc.abstractmethod + def is_cancellable(self): + """Does this task gracefully handle being cancelled (STAT_ENDED, STAT_CANCELLED)?""" + raise NotImplementedError + def start(self, *args): self.start_time = datetime.now() self.stat = STAT_STARTED @@ -208,7 +223,7 @@ class CalibreTask: We have a separate dictating this because there may be certain tasks that want to override this """ # By default, we're good to clean a task if it's "Done" - return self.stat in (STAT_FINISH_SUCCESS, STAT_FAIL) + return self.stat in (STAT_FINISH_SUCCESS, STAT_FAIL, STAT_ENDED, STAT_CANCELLED) '''@progress.setter def progress(self, x): @@ -226,6 +241,14 @@ class CalibreTask: def self_cleanup(self, is_self_cleanup): self._self_cleanup = is_self_cleanup + @property + def scheduled(self): + return self._scheduled + + @scheduled.setter + def scheduled(self, is_scheduled): + self._scheduled = is_scheduled + def _handleError(self, error_message): self.stat = STAT_FAIL self.progress = 1 diff --git a/cps/static/css/caliBlur.css b/cps/static/css/caliBlur.css index b4fa6045..b2b35423 100644 --- a/cps/static/css/caliBlur.css +++ b/cps/static/css/caliBlur.css @@ -5150,7 +5150,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head pointer-events: none } -#DeleteDomain:hover:before, #RestartDialog:hover:before, #ShutdownDialog:hover:before, #StatusDialog:hover:before, #ClearCacheDialog:hover:before, #deleteButton, #deleteModal:hover:before, body.mailset > div.container-fluid > div > div.col-sm-10 > div.discover td > a:hover { +#DeleteDomain:hover:before, #RestartDialog:hover:before, #ShutdownDialog:hover:before, #StatusDialog:hover:before, #deleteButton, #deleteModal:hover:before, #cancelTaskModal:hover:before, body.mailset > div.container-fluid > div > div.col-sm-10 > div.discover td > a:hover { cursor: pointer } @@ -5237,6 +5237,10 @@ body.admin > div.container-fluid > div > div.col-sm-10 > div.container-fluid > d margin-bottom: 20px } +body.admin > div.container-fluid div.scheduled_tasks_details { + margin-bottom: 20px +} + body.admin .btn-default { margin-bottom: 10px } @@ -5468,7 +5472,7 @@ body.admin.modal-open .navbar { z-index: 0 !important } -#RestartDialog, #ShutdownDialog, #StatusDialog, #ClearCacheDialog, #deleteModal { +#RestartDialog, #ShutdownDialog, #StatusDialog, #deleteModal, #cancelTaskModal { top: 0; overflow: hidden; padding-top: 70px; @@ -5478,7 +5482,7 @@ body.admin.modal-open .navbar { background: rgba(0, 0, 0, .5) } -#RestartDialog:before, #ShutdownDialog:before, #StatusDialog:before, #ClearCacheDialog:before, #deleteModal:before { +#RestartDialog:before, #ShutdownDialog:before, #StatusDialog:before, #deleteModal:before, #cancelTaskModal:before { content: "\E208"; padding-right: 10px; display: block; @@ -5500,18 +5504,18 @@ body.admin.modal-open .navbar { z-index: 99 } -#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #ClearCacheDialog.in:before, #deleteModal.in:before { +#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before, #cancelTaskModal.in:before { -webkit-transform: translate(0, 0); -ms-transform: translate(0, 0); transform: translate(0, 0) } -#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #ClearCacheDialog > .modal-dialog, #deleteModal > .modal-dialog { +#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog, #cancelTaskModal > .modal-dialog { width: 450px; margin: auto } -#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #ClearCacheDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content { +#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content, #cancelTaskModal > .modal-dialog > .modal-content { max-height: calc(100% - 90px); -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5); box-shadow: 0 5px 15px rgba(0, 0, 0, .5); @@ -5522,7 +5526,7 @@ body.admin.modal-open .navbar { width: 450px } -#RestartDialog > .modal-dialog > .modal-content > .modal-header, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header, #StatusDialog > .modal-dialog > .modal-content > .modal-header, #ClearCacheDialog > .modal-dialog > .modal-content > .modal-header, #deleteModal > .modal-dialog > .modal-content > .modal-header { +#RestartDialog > .modal-dialog > .modal-content > .modal-header, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header, #StatusDialog > .modal-dialog > .modal-content > .modal-header, #deleteModal > .modal-dialog > .modal-content > .modal-header, #cancelTaskModal > .modal-dialog > .modal-content > .modal-header { padding: 15px 20px; border-radius: 3px 3px 0 0; line-height: 1.71428571; @@ -5535,7 +5539,7 @@ body.admin.modal-open .navbar { text-align: left } -#RestartDialog > .modal-dialog > .modal-content > .modal-header:before, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header:before, #StatusDialog > .modal-dialog > .modal-content > .modal-header:before, #ClearCacheDialog > .modal-dialog > .modal-content > .modal-header:before, #deleteModal > .modal-dialog > .modal-content > .modal-header:before { +#RestartDialog > .modal-dialog > .modal-content > .modal-header:before, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header:before, #StatusDialog > .modal-dialog > .modal-content > .modal-header:before, #deleteModal > .modal-dialog > .modal-content > .modal-header:before, #cancelTaskModal > .modal-dialog > .modal-content > .modal-header:before { padding-right: 10px; font-size: 18px; color: #999; @@ -5559,12 +5563,12 @@ body.admin.modal-open .navbar { font-family: plex-icons-new, serif } -#ClearCacheDialog > .modal-dialog > .modal-content > .modal-header:before { - content: "\EA15"; +#deleteModal > .modal-dialog > .modal-content > .modal-header:before { + content: "\EA6D"; font-family: plex-icons-new, serif } -#deleteModal > .modal-dialog > .modal-content > .modal-header:before { +#cancelTaskModal > .modal-dialog > .modal-content > .modal-header:before { content: "\EA6D"; font-family: plex-icons-new, serif } @@ -5587,19 +5591,19 @@ body.admin.modal-open .navbar { font-size: 20px } -#ClearCacheDialog > .modal-dialog > .modal-content > .modal-header:after { - content: "Clear Cover Thumbnail Cache"; +#deleteModal > .modal-dialog > .modal-content > .modal-header:after { + content: "Delete Book"; display: inline-block; font-size: 20px } -#deleteModal > .modal-dialog > .modal-content > .modal-header:after { +#cancelTaskModal > .modal-dialog > .modal-content > .modal-header:after { content: "Delete Book"; display: inline-block; font-size: 20px } -#StatusDialog > .modal-dialog > .modal-content > .modal-header > span, #deleteModal > .modal-dialog > .modal-content > .modal-header > span, #loader > center > img, .rating-mobile { +#StatusDialog > .modal-dialog > .modal-content > .modal-header > span, #deleteModal > .modal-dialog > .modal-content > .modal-header > span, #cancelTaskModal > .modal-dialog > .modal-content > .modal-header > span, #loader > center > img, .rating-mobile { display: none } @@ -5613,7 +5617,7 @@ body.admin.modal-open .navbar { text-align: left } -#ShutdownDialog > .modal-dialog > .modal-content > .modal-body, #StatusDialog > .modal-dialog > .modal-content > .modal-body, #deleteModal > .modal-dialog > .modal-content > .modal-body { +#ShutdownDialog > .modal-dialog > .modal-content > .modal-body, #StatusDialog > .modal-dialog > .modal-content > .modal-body, #deleteModal > .modal-dialog > .modal-content > .modal-body, #cancelTaskModal > .modal-dialog > .modal-content > .modal-body { padding: 20px 20px 40px; font-size: 16px; line-height: 1.6em; @@ -5623,17 +5627,7 @@ body.admin.modal-open .navbar { text-align: left } -#ClearCacheDialog > .modal-dialog > .modal-content > .modal-body { - padding: 20px 20px 10px; - font-size: 16px; - line-height: 1.6em; - font-family: Open Sans Regular, Helvetica Neue, Helvetica, Arial, sans-serif; - color: #eee; - background: #282828; - text-align: left -} - -#RestartDialog > .modal-dialog > .modal-content > .modal-body > p, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > p, #StatusDialog > .modal-dialog > .modal-content > .modal-body > p, #ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > p, #deleteModal > .modal-dialog > .modal-content > .modal-body > p { +#RestartDialog > .modal-dialog > .modal-content > .modal-body > p, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > p, #StatusDialog > .modal-dialog > .modal-content > .modal-body > p, #deleteModal > .modal-dialog > .modal-content > .modal-body > p, #cancelTaskModal > .modal-dialog > .modal-content > .modal-body > p { padding: 20px 20px 0 0; font-size: 16px; line-height: 1.6em; @@ -5642,7 +5636,7 @@ body.admin.modal-open .navbar { background: #282828 } -#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart), #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown), #ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#clear_cache), #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default { +#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart), #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown), #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default, #cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-default { float: right; z-index: 9; position: relative; @@ -5678,11 +5672,11 @@ body.admin.modal-open .navbar { border-radius: 3px } -#ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > #clear_cache { +#deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-danger { float: right; z-index: 9; position: relative; - margin: 25px 0 0 10px; + margin: 0 0 0 10px; min-width: 80px; padding: 10px 18px; font-size: 16px; @@ -5690,7 +5684,7 @@ body.admin.modal-open .navbar { border-radius: 3px } -#deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-danger { +#cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-danger { float: right; z-index: 9; position: relative; @@ -5710,15 +5704,15 @@ body.admin.modal-open .navbar { margin: 55px 0 0 10px } -#ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#clear_cache) { - margin: 25px 0 0 10px +#deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default { + margin: 0 0 0 10px } -#deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default { +#cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-default { margin: 0 0 0 10px } -#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart):hover, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown):hover, #ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#clear_cache):hover, #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover { +#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart):hover, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown):hover, #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover, #cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover { background-color: hsla(0, 0%, 100%, .3) } @@ -5752,21 +5746,6 @@ body.admin.modal-open .navbar { box-shadow: 0 5px 15px rgba(0, 0, 0, .5) } -#ClearCacheDialog > .modal-dialog > .modal-content > .modal-body:after { - content: ''; - position: absolute; - width: 100%; - height: 72px; - background-color: #323232; - border-radius: 0 0 3px 3px; - left: 0; - margin-top: 10px; - z-index: 0; - border-top: 1px solid #222; - -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5); - box-shadow: 0 5px 15px rgba(0, 0, 0, .5) -} - #deleteButton { position: fixed; top: 60px; @@ -7355,11 +7334,11 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div. background-color: transparent !important } - #RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #ClearCacheDialog > .modal-dialog, #deleteModal > .modal-dialog { + #RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog, #cancelTaskModal > .modal-dialog { max-width: calc(100vw - 40px) } - #RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #ClearCacheDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content { + #RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content, #cancelTaskModal > .modal-dialog > .modal-content { max-width: calc(100vw - 40px); left: 0 } @@ -7509,7 +7488,7 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div. padding: 30px 15px } - #RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #ClearCacheDialog.in:before, #deleteModal.in:before { + #RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before, #cancelTaskModal.in:before { left: auto; right: 34px } diff --git a/cps/static/js/main.js b/cps/static/js/main.js index 5537d189..988e3b9f 100644 --- a/cps/static/js/main.js +++ b/cps/static/js/main.js @@ -454,18 +454,6 @@ $(function() { } }); }); - $("#clear_cache").click(function () { - $("#spinner3").show(); - $.ajax({ - dataType: "json", - url: window.location.pathname + "/../../clear-cache", - data: {"cache_type":"thumbnails"}, - success: function(data) { - $("#spinner3").hide(); - $("#ClearCacheDialog").modal("hide"); - } - }); - }); // Init all data control handlers to default $("input[data-control]").trigger("change"); diff --git a/cps/static/js/table.js b/cps/static/js/table.js index a55ec5d1..dc4ab4ab 100644 --- a/cps/static/js/table.js +++ b/cps/static/js/table.js @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -/* exported TableActions, RestrictionActions, EbookActions, responseHandler */ +/* exported TableActions, RestrictionActions, EbookActions, TaskActions, responseHandler */ /* global getPath, confirmDialog */ var selections = []; @@ -42,6 +42,24 @@ $(function() { }, 1000); } + $("#cancel_task_confirm").click(function() { + //get data-id attribute of the clicked element + var taskId = $(this).data("task-id"); + $.ajax({ + method: "post", + contentType: "application/json; charset=utf-8", + dataType: "json", + url: window.location.pathname + "/../ajax/canceltask", + data: JSON.stringify({"task_id": taskId}), + }); + }); + //triggered when modal is about to be shown + $("#cancelTaskModal").on("show.bs.modal", function(e) { + //get data-id attribute of the clicked element and store in button + var taskId = $(e.relatedTarget).data("task-id"); + $(e.currentTarget).find("#cancel_task_confirm").data("task-id", taskId); + }); + $("#books-table").on("check.bs.table check-all.bs.table uncheck.bs.table uncheck-all.bs.table", function (e, rowsAfter, rowsBefore) { var rows = rowsAfter; @@ -576,6 +594,7 @@ function handle_header_buttons () { $(".header_select").removeAttr("disabled"); } } + /* Function for deleting domain restrictions */ function TableActions (value, row) { return [ @@ -613,6 +632,19 @@ function UserActions (value, row) { ].join(""); } +/* Function for cancelling tasks */ +function TaskActions (value, row) { + var cancellableStats = [0, 1, 2]; + if (row.id && row.is_cancellable && cancellableStats.includes(row.stat)) { + return [ + "
", + "", + "
" + ].join(""); + } + return ''; +} + /* Function for keeping checked rows */ function responseHandler(res) { $.each(res.rows, function (i, row) { diff --git a/cps/tasks/convert.py b/cps/tasks/convert.py index 56cc7076..f150a397 100644 --- a/cps/tasks/convert.py +++ b/cps/tasks/convert.py @@ -234,3 +234,7 @@ class TaskConvert(CalibreTask): @property def name(self): return "Convert" + + @property + def is_cancellable(self): + return False diff --git a/cps/tasks/database.py b/cps/tasks/database.py index 11f0186d..0441d564 100644 --- a/cps/tasks/database.py +++ b/cps/tasks/database.py @@ -47,3 +47,7 @@ class TaskReconnectDatabase(CalibreTask): @property def name(self): return "Reconnect Database" + + @property + def is_cancellable(self): + return False diff --git a/cps/tasks/mail.py b/cps/tasks/mail.py index 292114d5..24064bd3 100644 --- a/cps/tasks/mail.py +++ b/cps/tasks/mail.py @@ -162,7 +162,6 @@ class TaskEmail(CalibreTask): log.debug_or_exception(ex) self._handleError(u'Error sending e-mail: {}'.format(ex)) - def send_standard_email(self, msg): use_ssl = int(self.settings.get('mail_use_ssl', 0)) timeout = 600 # set timeout to 5mins @@ -218,7 +217,6 @@ class TaskEmail(CalibreTask): self.asyncSMTP = None self._progress = x - @classmethod def _get_attachment(cls, bookpath, filename): """Get file as MIMEBase message""" @@ -260,5 +258,9 @@ class TaskEmail(CalibreTask): def name(self): return "E-mail" + @property + def is_cancellable(self): + return False + def __str__(self): return "{}, {}".format(self.name, self.subject) diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py index a220fd8c..d147f10d 100644 --- a/cps/tasks/thumbnail.py +++ b/cps/tasks/thumbnail.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) -# Copyright (C) 2020 mmonkey +# Copyright (C) 2020 monkey # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -21,8 +21,8 @@ import os from .. import constants from cps import config, db, fs, gdriveutils, logger, ub -from cps.services.worker import CalibreTask -from datetime import datetime, timedelta +from cps.services.worker import CalibreTask, STAT_CANCELLED, STAT_ENDED +from datetime import datetime from sqlalchemy import func, text, or_ try: @@ -67,7 +67,7 @@ def get_best_fit(width, height, image_width, image_height): class TaskGenerateCoverThumbnails(CalibreTask): - def __init__(self, task_message=u'Generating cover thumbnails'): + def __init__(self, task_message=''): super(TaskGenerateCoverThumbnails, self).__init__(task_message) self.log = logger.create() self.app_db_session = ub.get_new_session_instance() @@ -79,13 +79,14 @@ class TaskGenerateCoverThumbnails(CalibreTask): ] def run(self, worker_thread): - if self.calibre_db.session and use_IM: + if self.calibre_db.session and use_IM and self.stat != STAT_CANCELLED and self.stat != STAT_ENDED: + self.message = 'Scanning Books' books_with_covers = self.get_books_with_covers() count = len(books_with_covers) - updated = 0 - generated = 0 + total_generated = 0 for i, book in enumerate(books_with_covers): + generated = 0 book_cover_thumbnails = self.get_book_cover_thumbnails(book.id) # Generate new thumbnails for missing covers @@ -98,16 +99,32 @@ class TaskGenerateCoverThumbnails(CalibreTask): # Replace outdated or missing thumbnails for thumbnail in book_cover_thumbnails: if book.last_modified > thumbnail.generated_at: - updated += 1 + generated += 1 self.update_book_cover_thumbnail(book, thumbnail) elif not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS): - updated += 1 + generated += 1 self.update_book_cover_thumbnail(book, thumbnail) - self.message = u'Processing book {0} of {1}'.format(i + 1, count) + # Increment the progress self.progress = (1.0 / count) * i + if generated > 0: + total_generated += generated + self.message = u'Generated {0} cover thumbnails'.format(total_generated) + + # Check if job has been cancelled or ended + if self.stat == STAT_CANCELLED: + self.log.info(f'GenerateCoverThumbnails task has been cancelled.') + return + + if self.stat == STAT_ENDED: + self.log.info(f'GenerateCoverThumbnails task has been ended.') + return + + if total_generated == 0: + self.self_cleanup = True + self._handleSuccess() self.app_db_session.remove() @@ -180,7 +197,8 @@ class TaskGenerateCoverThumbnails(CalibreTask): self.log.info(u'Error generating thumbnail file: ' + str(ex)) raise ex finally: - stream.close() + if stream is not None: + stream.close() else: book_cover_filepath = os.path.join(config.config_calibre_dir, book.path, 'cover.jpg') if not os.path.isfile(book_cover_filepath): @@ -197,11 +215,15 @@ class TaskGenerateCoverThumbnails(CalibreTask): @property def name(self): - return "ThumbnailsGenerate" + return 'GenerateCoverThumbnails' + + @property + def is_cancellable(self): + return True class TaskGenerateSeriesThumbnails(CalibreTask): - def __init__(self, task_message=u'Generating series thumbnails'): + def __init__(self, task_message=''): super(TaskGenerateSeriesThumbnails, self).__init__(task_message) self.log = logger.create() self.app_db_session = ub.get_new_session_instance() @@ -209,17 +231,18 @@ class TaskGenerateSeriesThumbnails(CalibreTask): self.cache = fs.FileSystem() self.resolutions = [ constants.COVER_THUMBNAIL_SMALL, - constants.COVER_THUMBNAIL_MEDIUM + constants.COVER_THUMBNAIL_MEDIUM, ] def run(self, worker_thread): - if self.calibre_db.session and use_IM: + if self.calibre_db.session and use_IM and self.stat != STAT_CANCELLED and self.stat != STAT_ENDED: + self.message = 'Scanning Series' all_series = self.get_series_with_four_plus_books() count = len(all_series) - updated = 0 - generated = 0 + total_generated = 0 for i, series in enumerate(all_series): + generated = 0 series_thumbnails = self.get_series_thumbnails(series.id) series_books = self.get_series_books(series.id) @@ -233,16 +256,32 @@ class TaskGenerateSeriesThumbnails(CalibreTask): # Replace outdated or missing thumbnails for thumbnail in series_thumbnails: if any(book.last_modified > thumbnail.generated_at for book in series_books): - updated += 1 + generated += 1 self.update_series_thumbnail(series_books, thumbnail) elif not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS): - updated += 1 + generated += 1 self.update_series_thumbnail(series_books, thumbnail) - self.message = u'Processing series {0} of {1}'.format(i + 1, count) + # Increment the progress self.progress = (1.0 / count) * i + if generated > 0: + total_generated += generated + self.message = u'Generated {0} series thumbnails'.format(total_generated) + + # Check if job has been cancelled or ended + if self.stat == STAT_CANCELLED: + self.log.info(f'GenerateSeriesThumbnails task has been cancelled.') + return + + if self.stat == STAT_ENDED: + self.log.info(f'GenerateSeriesThumbnails task has been ended.') + return + + if total_generated == 0: + self.self_cleanup = True + self._handleSuccess() self.app_db_session.remove() @@ -302,7 +341,8 @@ class TaskGenerateSeriesThumbnails(CalibreTask): self.app_db_session.rollback() def generate_series_thumbnail(self, series_books, thumbnail): - books = series_books[:4] + # Get the last four books in the series based on series_index + books = sorted(series_books, key=lambda b: float(b.series_index), reverse=True)[:4] top = 0 left = 0 @@ -342,7 +382,8 @@ class TaskGenerateSeriesThumbnails(CalibreTask): self.log.info(u'Error generating thumbnail file: ' + str(ex)) raise ex finally: - stream.close() + if stream is not None: + stream.close() book_cover_filepath = os.path.join(config.config_calibre_dir, book.path, 'cover.jpg') if not os.path.isfile(book_cover_filepath): @@ -380,11 +421,15 @@ class TaskGenerateSeriesThumbnails(CalibreTask): @property def name(self): - return "SeriesThumbnailGenerate" + return 'GenerateSeriesThumbnails' + + @property + def is_cancellable(self): + return True class TaskClearCoverThumbnailCache(CalibreTask): - def __init__(self, book_id=None, task_message=u'Clearing cover thumbnail cache'): + def __init__(self, book_id, task_message=u'Clearing cover thumbnail cache'): super(TaskClearCoverThumbnailCache, self).__init__(task_message) self.log = logger.create() self.book_id = book_id @@ -397,8 +442,6 @@ class TaskClearCoverThumbnailCache(CalibreTask): thumbnails = self.get_thumbnails_for_book(self.book_id) for thumbnail in thumbnails: self.delete_thumbnail(thumbnail) - else: - self.delete_all_thumbnails() self._handleSuccess() self.app_db_session.remove() @@ -411,19 +454,19 @@ class TaskClearCoverThumbnailCache(CalibreTask): .all() def delete_thumbnail(self, thumbnail): + thumbnail.expiration = datetime.utcnow() + try: + self.app_db_session.commit() self.cache.delete_cache_file(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS) except Exception as ex: self.log.info(u'Error deleting book thumbnail: ' + str(ex)) self._handleError(u'Error deleting book thumbnail: ' + str(ex)) - def delete_all_thumbnails(self): - try: - self.cache.delete_cache_dir(constants.CACHE_TYPE_THUMBNAILS) - except Exception as ex: - self.log.info(u'Error deleting book thumbnails: ' + str(ex)) - self._handleError(u'Error deleting book thumbnails: ' + str(ex)) - @property def name(self): - return "ThumbnailsClear" + return 'ThumbnailsClear' + + @property + def is_cancellable(self): + return False diff --git a/cps/tasks/upload.py b/cps/tasks/upload.py index d7ef34c2..9f58bf16 100644 --- a/cps/tasks/upload.py +++ b/cps/tasks/upload.py @@ -16,3 +16,7 @@ class TaskUpload(CalibreTask): @property def name(self): return "Upload" + + @property + def is_cancellable(self): + return False diff --git a/cps/templates/admin.html b/cps/templates/admin.html index ec0fc84e..3fd55ae2 100644 --- a/cps/templates/admin.html +++ b/cps/templates/admin.html @@ -159,7 +159,7 @@

{{_('Scheduled Tasks')}}

-
+
{{_('Time at which tasks start to run')}}
{{config.schedule_start_time}}:00
diff --git a/cps/templates/schedule_edit.html b/cps/templates/schedule_edit.html index f4e72224..71bb2d1a 100644 --- a/cps/templates/schedule_edit.html +++ b/cps/templates/schedule_edit.html @@ -11,7 +11,7 @@
@@ -19,12 +19,12 @@
- +
diff --git a/cps/templates/tasks.html b/cps/templates/tasks.html index c13ddff9..b36a6daa 100644 --- a/cps/templates/tasks.html +++ b/cps/templates/tasks.html @@ -16,6 +16,9 @@ {{_('Progress')}} {{_('Run Time')}} {{_('Start Time')}} + {% if g.user.role_admin() %} + {{_('Actions')}} + {% endif %} @@ -23,6 +26,30 @@
{% endblock %} +{% block modal %} +{{ delete_book() }} +{% if g.user.role_admin() %} + +{% endif %} +{% endblock %} {% block js %} diff --git a/cps/web.py b/cps/web.py index 88a5c0ab..c5ad2265 100644 --- a/cps/web.py +++ b/cps/web.py @@ -124,7 +124,7 @@ def viewer_required(f): @web.route("/ajax/emailstat") @login_required def get_email_status_json(): - tasks = WorkerThread.getInstance().tasks + tasks = WorkerThread.get_instance().tasks return jsonify(render_task_status(tasks)) @@ -1055,7 +1055,7 @@ def category_list(): @login_required def get_tasks_status(): # if current user admin, show all email, otherwise only own emails - tasks = WorkerThread.getInstance().tasks + tasks = WorkerThread.get_instance().tasks answer = render_task_status(tasks) return render_title_template('tasks.html', entries=answer, title=_(u"Tasks"), page="tasks")