diff options
author | RaNaN <Mast3rRaNaN@hotmail.de> | 2013-03-23 21:56:42 +0100 |
---|---|---|
committer | RaNaN <Mast3rRaNaN@hotmail.de> | 2013-03-23 21:56:42 +0100 |
commit | 6e8a0f79f5ad7182a0bc35308ae06c63222667ed (patch) | |
tree | 0026179d34f19b64bc689c63af85b949ce57fb83 | |
parent | show button when files are selected (diff) | |
download | pyload-6e8a0f79f5ad7182a0bc35308ae06c63222667ed.tar.xz |
implemented interactions for multi user, show waiting queries on webui
-rw-r--r-- | module/api/CoreApi.py | 5 | ||||
-rw-r--r-- | module/api/UserInteractionApi.py | 32 | ||||
-rw-r--r-- | module/config/default.py | 1 | ||||
-rw-r--r-- | module/interaction/InteractionManager.py | 143 | ||||
-rw-r--r-- | module/interaction/InteractionTask.py | 37 | ||||
-rw-r--r-- | module/plugins/Base.py | 5 | ||||
-rw-r--r-- | module/plugins/hoster/BasePlugin.py | 6 | ||||
-rw-r--r-- | module/remote/apitypes.py | 38 | ||||
-rw-r--r-- | module/remote/apitypes_debug.py | 16 | ||||
-rw-r--r-- | module/remote/pyload.thrift | 34 | ||||
-rw-r--r-- | module/remote/wsbackend/AsyncHandler.py | 20 | ||||
-rw-r--r-- | module/web/static/css/default/style.less | 10 | ||||
-rw-r--r-- | module/web/static/js/collections/InteractionList.js | 47 | ||||
-rw-r--r-- | module/web/static/js/models/InteractionTask.js | 27 | ||||
-rw-r--r-- | module/web/static/js/utils/apitypes.js | 4 | ||||
-rw-r--r-- | module/web/static/js/views/headerView.js | 18 | ||||
-rw-r--r-- | module/web/static/js/views/notificationView.js | 65 | ||||
-rw-r--r-- | module/web/templates/default/base.html | 21 | ||||
-rw-r--r-- | tests/manager/test_interactionManager.py | 71 |
19 files changed, 415 insertions, 185 deletions
diff --git a/module/api/CoreApi.py b/module/api/CoreApi.py index d75fe6ad6..e5c5e8b41 100644 --- a/module/api/CoreApi.py +++ b/module/api/CoreApi.py @@ -1,13 +1,12 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from module.Api import Api, RequirePerm, Permission, ServerStatus +from module.Api import Api, RequirePerm, Permission, ServerStatus, Interaction from module.utils.fs import join, free_space from module.utils import compare_time from ApiComponent import ApiComponent - class CoreApi(ApiComponent): """ This module provides methods for general interaction with the core, like status or progress retrieval """ @@ -34,7 +33,7 @@ class CoreApi(ApiComponent): serverStatus = ServerStatus(0, total[0], queue[0], total[1], queue[1], - 0, + self.isInteractionWaiting(Interaction.All), not self.core.threadManager.pause and self.isTimeDownload(), self.core.threadManager.pause, self.core.config['reconnect']['activated'] and self.isTimeReconnect()) diff --git a/module/api/UserInteractionApi.py b/module/api/UserInteractionApi.py index ded305c30..b95b7c468 100644 --- a/module/api/UserInteractionApi.py +++ b/module/api/UserInteractionApi.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from module.Api import Api, RequirePerm, Permission, InteractionTask +from module.Api import Api, RequirePerm, Permission, Interaction from ApiComponent import ApiComponent @@ -15,40 +15,36 @@ class UserInteractionApi(ApiComponent): :param mode: binary or'ed output type :return: boolean """ - return self.core.interactionManager.isTaskWaiting(mode) + return self.core.interactionManager.isTaskWaiting(self.primaryUID, mode) @RequirePerm(Permission.Interaction) - def getInteractionTask(self, mode): + def getInteractionTasks(self, mode): """Retrieve task for specific mode. - :param mode: binary or'ed output type - :return: :class:`InteractionTask` + :param mode: binary or'ed interaction types which should be retrieved + :rtype list of :class:`InteractionTask` """ - task = self.core.interactionManager.getTask(mode) - return InteractionTask(-1) if not task else task + tasks = self.core.interactionManager.getTasks(self.primaryUID, mode) + # retrieved tasks count as seen + for t in tasks: + t.seen = True + if t.type == Interaction.Notification: + t.setWaiting(self.core.interactionManager.CLIENT_THRESHOLD) + return tasks @RequirePerm(Permission.Interaction) def setInteractionResult(self, iid, result): """Set Result for a interaction task. It will be immediately removed from task queue afterwards :param iid: interaction id - :param result: result as string + :param result: result as json string """ task = self.core.interactionManager.getTaskByID(iid) - if task: + if task and self.primaryUID == task.owner: task.setResult(result) @RequirePerm(Permission.Interaction) - def getNotifications(self): - """List of all available notifcations. They stay in queue for some time, client should\ - save which notifications it already has seen. - - :return: list of :class:`InteractionTask` - """ - return self.core.interactionManager.getNotifications() - - @RequirePerm(Permission.Interaction) def getAddonHandler(self): pass diff --git a/module/config/default.py b/module/config/default.py index 8515a8f33..dfa967284 100644 --- a/module/config/default.py +++ b/module/config/default.py @@ -5,6 +5,7 @@ Configuration layout for default base config """ #TODO: write tooltips and descriptions +#TODO: use apis config related classes def make_config(config): # Check if gettext is installed diff --git a/module/interaction/InteractionManager.py b/module/interaction/InteractionManager.py index 1d26b1665..e4ae05501 100644 --- a/module/interaction/InteractionManager.py +++ b/module/interaction/InteractionManager.py @@ -17,11 +17,13 @@ """ from threading import Lock from time import time +from base64 import standard_b64encode from new_collections import OrderedDict -from module.utils import lock, bits_set, to_list -from module.Api import Input, Output +from module.utils import lock, bits_set +from module.Api import Interaction as IA +from module.Api import InputType, Input from InteractionTask import InteractionTask @@ -29,51 +31,38 @@ class InteractionManager: """ Class that gives ability to interact with the user. Arbitrary tasks with predefined output and input types can be set off. - Asynchronous callbacks and default values keep the ability to fallback if no user is present. """ # number of seconds a client is classified as active CLIENT_THRESHOLD = 60 + NOTIFICATION_TIMEOUT = 60 * 60 * 30 + MAX_NOTIFICATIONS = 50 def __init__(self, core): self.lock = Lock() self.core = core - self.tasks = OrderedDict() #task store, for outgoing tasks only - self.notifications = [] #list of notifications + self.tasks = OrderedDict() #task store, for all outgoing tasks - self.last_clients = { - Output.Notification : 0, - Output.Captcha : 0, - Output.Query : 0, - } + self.last_clients = {} + self.ids = 0 #uniue interaction ids - self.ids = 0 #only for internal purpose - - - def isClientConnected(self, mode=Output.All): - if mode == Output.All: - return max(self.last_clients.values()) + self.CLIENT_THRESHOLD <= time() - else: - self.last_clients.get(mode, 0) + self.CLIENT_THRESHOLD <= time() - - def updateClient(self, mode): - t = time() - for output in self.last_clients: - if bits_set(output, mode): - self.last_clients[output] = t + def isClientConnected(self, user): + return self.last_clients.get(user, 0) + self.CLIENT_THRESHOLD > time() @lock def work(self): # old notifications will be removed - for n in [x for x in self.notifications if x.timedOut()]: - self.notifications.remove(n) - - # store at most 100 notifications - del self.notifications[50:] + for n in [k for k, v in self.tasks.iteritems() if v.timedOut()]: + del self.tasks[n] + # keep notifications count limited + n = [k for k,v in self.tasks.iteritems() if v.type == IA.Notification] + n.reverse() + for v in n[:self.MAX_NOTIFICATIONS]: + del self.tasks[v] @lock - def createNotification(self, title, content, desc="", plugin=""): + def createNotification(self, title, content, desc="", plugin="", owner=None): """ Creates and queues a new Notification :param title: short title @@ -82,67 +71,86 @@ class InteractionManager: :param plugin: plugin name :return: :class:`InteractionTask` """ - task = InteractionTask(self.ids, Input.Text, [content], Output.Notification, "", title, desc, plugin) + task = InteractionTask(self.ids, IA.Notification, Input(InputType.Text, content), "", title, desc, plugin, + owner=owner) self.ids += 1 - self.notifications.insert(0, task) - self.handleTask(task) + self.queueTask(task) return task @lock - def newQueryTask(self, input, data, desc, default="", plugin=""): - task = InteractionTask(self.ids, input, to_list(data), Output.Query, default, _("Query"), desc, plugin) + def createQueryTask(self, input, desc, default="", plugin="", owner=None): + # input type was given, create a input widget + if type(input) == int: + input = Input(input) + if not isinstance(input, Input): + raise TypeError("'Input' class expected not '%s'" % type(input)) + + task = InteractionTask(self.ids, IA.Query, input, default, _("Query"), desc, plugin, owner=owner) self.ids += 1 + self.queueTask(task) return task @lock - def newCaptchaTask(self, img, format, filename, plugin="", input=Input.Text): + def createCaptchaTask(self, img, format, filename, plugin="", type=InputType.Text, owner=None): + """ Createss a new captcha task. + + :param img: image content (not base encoded) + :param format: img format + :param type: :class:`InputType` + :return: + """ + if type == 'textual': + type = InputType.Text + elif type == 'positional': + type = InputType.Click + + input = Input(type, [standard_b64encode(img), format, filename]) + #todo: title desc plugin - task = InteractionTask(self.ids, input, [img, format, filename],Output.Captcha, - "", _("Captcha request"), _("Please solve the captcha."), plugin) + task = InteractionTask(self.ids, IA.Captcha, input, + None, _("Captcha request"), _("Please solve the captcha."), plugin, owner=owner) + self.ids += 1 + self.queueTask(task) return task @lock def removeTask(self, task): if task.iid in self.tasks: del self.tasks[task.iid] + self.core.evm.dispatchEvent("interaction:deleted", task.iid) @lock - def getTask(self, mode=Output.All): - self.updateClient(mode) - - for task in self.tasks.itervalues(): - if mode == Output.All or bits_set(task.output, mode): - return task + def getTaskByID(self, iid): + return self.tasks.get(iid, None) @lock - def getNotifications(self): - """retrieves notifications, old ones are only deleted after a while\ - client has to make sure itself to dont display it twice""" - for n in self.notifications: - n.setWaiting(self.CLIENT_THRESHOLD * 5, True) - #store notification for shorter period, lock the timeout + def getTasks(self, user, mode=IA.All): + # update last active clients + self.last_clients[user] = time() - return self.notifications + # filter current mode + tasks = [t for t in self.tasks.itervalues() if mode == IA.All or bits_set(t.type, mode)] + # filter correct user / or shared + tasks = [t for t in tasks if user is None or user == t.owner or t.shared] - def isTaskWaiting(self, mode=Output.All): - return self.getTask(mode) is not None + return tasks - @lock - def getTaskByID(self, iid): - if iid in self.tasks: - task = self.tasks[iid] - del self.tasks[iid] - return task + def isTaskWaiting(self, user, mode=IA.All): + tasks = [t for t in self.getTasks(user, mode) if not t.type == IA.Notification or not t.seen] + return len(tasks) > 0 - def handleTask(self, task): - cli = self.isClientConnected(task.output) + def queueTask(self, task): + cli = self.isClientConnected(task.owner) - if cli: #client connected -> should handle the task - task.setWaiting(self.CLIENT_THRESHOLD) # wait for response + # set waiting times based on threshold + if cli: + task.setWaiting(self.CLIENT_THRESHOLD) + else: # TODO: higher threshold after client connects? + task.setWaiting(self.CLIENT_THRESHOLD / 3) - if task.output == Output.Notification: - task.setWaiting(60 * 60 * 30) # notifications are valid for 30h + if task.type == IA.Notification: + task.setWaiting(self.NOTIFICATION_TIMEOUT) # notifications are valid for 30h for plugin in self.core.addonManager.activePlugins(): try: @@ -150,10 +158,9 @@ class InteractionManager: except: self.core.print_exc() - if task.output != Output.Notification: - self.tasks[task.iid] = task + self.tasks[task.iid] = task + self.core.evm.dispatchEvent("interaction:added", task) if __name__ == "__main__": - it = InteractionTask()
\ No newline at end of file diff --git a/module/interaction/InteractionTask.py b/module/interaction/InteractionTask.py index b372321b0..d2877b2b0 100644 --- a/module/interaction/InteractionTask.py +++ b/module/interaction/InteractionTask.py @@ -19,12 +19,12 @@ from time import time from module.Api import InteractionTask as BaseInteractionTask -from module.Api import Input, Output +from module.Api import Interaction, InputType, Input #noinspection PyUnresolvedReferences class InteractionTask(BaseInteractionTask): """ - General Interaction Task extends ITask defined by thrift with additional fields and methods. + General Interaction Task extends ITask defined by api with additional fields and methods. """ #: Plugins can put needed data here storage = None @@ -38,8 +38,21 @@ class InteractionTask(BaseInteractionTask): error = None #: Timeout locked locked = False + #: A task that was retrieved counts as seen + seen = False + #: A task that is relevant to every user + shared = False + #: primary uid of the owner + owner = None def __init__(self, *args, **kwargs): + if 'owner' in kwargs: + self.owner = kwargs['owner'] + del kwargs['owner'] + if 'shared' in kwargs: + self.shared = kwargs['shared'] + del kwargs['shared'] + BaseInteractionTask.__init__(self, *args, **kwargs) # additional internal attributes @@ -54,28 +67,34 @@ class InteractionTask(BaseInteractionTask): def getResult(self): return self.result + def setShared(self): + """ enable shared mode, should not be reversed""" + self.shared = True + def setResult(self, value): self.result = self.convertResult(value) def setWaiting(self, sec, lock=False): + """ sets waiting in seconds from now, < 0 can be used as infinitive """ if not self.locked: - self.wait_until = max(time() + sec, self.wait_until) + if sec < 0: + self.wait_until = -1 + else: + self.wait_until = max(time() + sec, self.wait_until) + if lock: self.locked = True def isWaiting(self): - if self.result or self.error or time() > self.waitUntil: + if self.result or self.error or self.timedOut(): return False return True def timedOut(self): - return time() > self.wait_until > 0 + return time() > self.wait_until > -1 def correct(self): [x.taskCorrect(self) for x in self.handler] def invalid(self): - [x.taskInvalid(self) for x in self.handler] - - def __str__(self): - return "<InteractionTask '%s'>" % self.id
\ No newline at end of file + [x.taskInvalid(self) for x in self.handler]
\ No newline at end of file diff --git a/module/plugins/Base.py b/module/plugins/Base.py index 6ae2da249..70805b7f3 100644 --- a/module/plugins/Base.py +++ b/module/plugins/Base.py @@ -304,9 +304,8 @@ class Base(object): ocr = OCR() result = ocr.get_captcha(temp_file.name) else: - task = self.im.newCaptchaTask(img, imgtype, temp_file.name, result_type) + task = self.im.createCaptchaTask(img, imgtype, temp_file.name, self.__name__, result_type) self.task = task - self.im.handleTask(task) while task.isWaiting(): if self.abort(): @@ -322,7 +321,7 @@ class Base(object): elif task.error: self.fail(task.error) elif not task.result: - self.fail(_("No captcha result obtained in appropriate time by any of the plugins.")) + self.fail(_("No captcha result obtained in appropriate time.")) result = task.result self.log.debug("Received captcha result: %s" % str(result)) diff --git a/module/plugins/hoster/BasePlugin.py b/module/plugins/hoster/BasePlugin.py index 293049a1a..c07164161 100644 --- a/module/plugins/hoster/BasePlugin.py +++ b/module/plugins/hoster/BasePlugin.py @@ -31,10 +31,8 @@ class BasePlugin(Hoster): #TODO: remove debug if pyfile.url.lower().startswith("debug"): - self.setWait(30) - self.wait() - self.decryptCaptcha("http://pyload.org/pie.png") - self.download("http://pyload.org/random100.bin") + self.decryptCaptcha("http://download.pyload.org/pie.png") + self.download("http://download.pyload.org/random100.bin") return # # if pyfile.url == "79": diff --git a/module/remote/apitypes.py b/module/remote/apitypes.py index 83368c6de..83eb19450 100644 --- a/module/remote/apitypes.py +++ b/module/remote/apitypes.py @@ -43,7 +43,7 @@ class FileStatus: Missing = 1 Remote = 2 -class Input: +class InputType: NA = 0 Text = 1 Int = 2 @@ -58,6 +58,12 @@ class Input: List = 11 Table = 12 +class Interaction: + All = 0 + Notification = 1 + Captcha = 2 + Query = 4 + class MediaType: All = 0 Other = 1 @@ -67,12 +73,6 @@ class MediaType: Document = 16 Archive = 32 -class Output: - All = 0 - Notification = 1 - Captcha = 2 - Query = 4 - class PackageStatus: Ok = 0 Paused = 1 @@ -150,13 +150,13 @@ class ConfigInfo(BaseObject): self.activated = activated class ConfigItem(BaseObject): - __slots__ = ['name', 'label', 'description', 'type', 'default_value', 'value'] + __slots__ = ['name', 'label', 'description', 'input', 'default_value', 'value'] - def __init__(self, name=None, label=None, description=None, type=None, default_value=None, value=None): + def __init__(self, name=None, label=None, description=None, input=None, default_value=None, value=None): self.name = name self.label = label self.description = description - self.type = type + self.input = input self.default_value = default_value self.value = value @@ -211,14 +211,20 @@ class FileInfo(BaseObject): class Forbidden(ExceptionObject): pass +class Input(BaseObject): + __slots__ = ['type', 'data'] + + def __init__(self, type=None, data=None): + self.type = type + self.data = data + class InteractionTask(BaseObject): - __slots__ = ['iid', 'input', 'data', 'output', 'default_value', 'title', 'description', 'plugin'] + __slots__ = ['iid', 'type', 'input', 'default_value', 'title', 'description', 'plugin'] - def __init__(self, iid=None, input=None, data=None, output=None, default_value=None, title=None, description=None, plugin=None): + def __init__(self, iid=None, type=None, input=None, default_value=None, title=None, description=None, plugin=None): self.iid = iid + self.type = type self.input = input - self.data = data - self.output = output self.default_value = default_value self.title = title self.description = description @@ -438,12 +444,10 @@ class Iface(object): pass def getFilteredFiles(self, state): pass - def getInteractionTask(self, mode): + def getInteractionTasks(self, mode): pass def getLog(self, offset): pass - def getNotifications(self): - pass def getPackageContent(self, pid): pass def getPackageInfo(self, pid): diff --git a/module/remote/apitypes_debug.py b/module/remote/apitypes_debug.py index 4fab11f96..6909464d4 100644 --- a/module/remote/apitypes_debug.py +++ b/module/remote/apitypes_debug.py @@ -9,9 +9,9 @@ enums = [ "DownloadState", "DownloadStatus", "FileStatus", - "Input", + "InputType", + "Interaction", "MediaType", - "Output", "PackageStatus", "Permission", "Role", @@ -23,21 +23,22 @@ classes = { 'AddonService' : [basestring, basestring, (list, basestring), (None, int)], 'ConfigHolder' : [basestring, basestring, basestring, basestring, (list, ConfigItem), (None, (list, AddonInfo)), (None, (list, InteractionTask))], 'ConfigInfo' : [basestring, basestring, basestring, basestring, bool, (None, bool)], - 'ConfigItem' : [basestring, basestring, basestring, basestring, (None, basestring), basestring], + 'ConfigItem' : [basestring, basestring, basestring, Input, basestring, basestring], 'DownloadInfo' : [basestring, basestring, basestring, int, basestring, basestring], 'DownloadProgress' : [int, int, int, int], 'EventInfo' : [basestring, (list, basestring)], 'FileDoesNotExists' : [int], 'FileInfo' : [int, basestring, int, int, int, int, int, int, int, (None, DownloadInfo)], - 'InteractionTask' : [int, int, (list, basestring), int, (None, basestring), basestring, basestring, basestring], + 'Input' : [int, (None, basestring)], + 'InteractionTask' : [int, int, Input, (None, basestring), basestring, basestring, basestring], 'InvalidConfigSection' : [basestring], 'LinkStatus' : [basestring, basestring, basestring, int, int, basestring], - 'OnlineCheck' : [int, (dict, basestring, LinkStatus)], + 'OnlineCheck' : [int, (None, (dict, basestring, LinkStatus))], 'PackageDoesNotExists' : [int], 'PackageInfo' : [int, basestring, basestring, int, int, basestring, basestring, basestring, int, (list, basestring), int, bool, int, PackageStats, (list, int), (list, int)], 'PackageStats' : [int, int, int, int], 'ProgressInfo' : [basestring, basestring, basestring, int, int, int, (None, DownloadProgress)], - 'ServerStatus' : [int, int, int, int, int, int, bool, bool, bool], + 'ServerStatus' : [int, int, int, int, int, bool, bool, bool, bool], 'ServiceDoesNotExists' : [basestring, basestring], 'ServiceException' : [basestring], 'TreeCollection' : [PackageInfo, (dict, int, FileInfo), (dict, int, PackageInfo)], @@ -86,9 +87,8 @@ methods = { 'getFileTree': TreeCollection, 'getFilteredFileTree': TreeCollection, 'getFilteredFiles': TreeCollection, - 'getInteractionTask': InteractionTask, + 'getInteractionTasks': (list, InteractionTask), 'getLog': (list, basestring), - 'getNotifications': (list, InteractionTask), 'getPackageContent': TreeCollection, 'getPackageInfo': PackageInfo, 'getPluginConfig': (list, ConfigInfo), diff --git a/module/remote/pyload.thrift b/module/remote/pyload.thrift index 06add4208..76e755de0 100644 --- a/module/remote/pyload.thrift +++ b/module/remote/pyload.thrift @@ -69,7 +69,7 @@ enum PackageStatus { // some may only be place holder currently not supported // also all input - output combination are not reasonable, see InteractionManager for further info // Todo: how about: time, ip, s.o. -enum Input { +enum InputType { NA, Text, Int, @@ -88,7 +88,7 @@ enum Input { // this describes the type of the outgoing interaction // ensure they can be logcial or'ed -enum Output { +enum Interaction { All = 0, Notification = 1, Captcha = 2, @@ -111,6 +111,11 @@ enum Role { User = 1 } +struct Input { + 1: InputType type, + 2: optional JSONString data, +} + struct DownloadProgress { 1: FileID fid, 2: PackageID pid, @@ -200,7 +205,7 @@ struct ServerStatus { 3: i16 linksqueue, 4: ByteCount sizetotal, 5: ByteCount sizequeue, - 6: i16 notifications, + 6: bool notifications, 7: bool paused, 8: bool download, 9: bool reconnect, @@ -208,13 +213,12 @@ struct ServerStatus { struct InteractionTask { 1: InteractionID iid, - 2: Input input, - 3: list<string> data, - 4: Output output, - 5: optional JSONString default_value, - 6: string title, - 7: string description, - 8: PluginName plugin, + 2: Interaction type, + 3: Input input, + 4: optional JSONString default_value, + 5: string title, + 6: string description, + 7: PluginName plugin, } struct AddonService { @@ -234,7 +238,7 @@ struct ConfigItem { 1: string name, 2: string label, 3: string description, - 4: string type, + 4: Input input, 5: JSONString default_value, 6: JSONString value, } @@ -360,7 +364,7 @@ service Pyload { map<string, ConfigHolder> getConfig(), string getConfigValue(1: string section, 2: string option), - // two methods with ambigous classification, could be configuration or addon related + // two methods with ambigous classification, could be configuration or addon/plugin related list<ConfigInfo> getCoreConfig(), list<ConfigInfo> getPluginConfig(), list<ConfigInfo> getAvailablePlugins(), @@ -473,16 +477,14 @@ service Pyload { // User Interaction /////////////////////// - // mode = Output types binary ORed + // mode = interaction types binary ORed bool isInteractionWaiting(1: i16 mode), - InteractionTask getInteractionTask(1: i16 mode), + list<InteractionTask> getInteractionTasks(1: i16 mode), void setInteractionResult(1: InteractionID iid, 2: JSONString result), // generate a download link, everybody can download the file until timeout reached string generateDownloadLink(1: FileID fid, 2: i16 timeout), - list<InteractionTask> getNotifications(), - /////////////////////// // Account Methods /////////////////////// diff --git a/module/remote/wsbackend/AsyncHandler.py b/module/remote/wsbackend/AsyncHandler.py index 99ffe9894..b40f0ea4e 100644 --- a/module/remote/wsbackend/AsyncHandler.py +++ b/module/remote/wsbackend/AsyncHandler.py @@ -23,7 +23,7 @@ from time import time from mod_pywebsocket.msgutil import receive_message -from module.Api import EventInfo +from module.Api import EventInfo, Interaction from module.utils import lock from AbstractHandler import AbstractHandler @@ -44,7 +44,8 @@ class AsyncHandler(AbstractHandler): COMMAND = "start" PROGRESS_INTERVAL = 2 - EVENT_PATTERN = re.compile(r"^(package|file)", re.I) + EVENT_PATTERN = re.compile(r"^(package|file|interaction)", re.I) + INTERACTION = Interaction.All def __init__(self, api): AbstractHandler.__init__(self, api) @@ -58,6 +59,7 @@ class AsyncHandler(AbstractHandler): req.queue = Queue() req.interval = self.PROGRESS_INTERVAL req.events = self.EVENT_PATTERN + req.interaction = self.INTERACTION req.mode = Mode.STANDBY req.t = time() # time when update should be pushed self.clients.append(req) @@ -76,6 +78,18 @@ class AsyncHandler(AbstractHandler): event = EventInfo(event, [x.toInfoData() if hasattr(x, 'toInfoData') else x for x in args]) for req in self.clients: + # filter events that these user is no owner of + # TODO: events are security critical, this should be revised later + if not req.api.user.isAdmin(): + skip = False + for arg in args: + if hasattr(arg, 'owner') and arg.owner != req.api.primaryUID: + skip = True + break + + # user should not get this event + if skip: break + if req.events.search(event.eventname): self.log.debug("Pushing event %s" % event) req.queue.put(event) @@ -115,6 +129,8 @@ class AsyncHandler(AbstractHandler): req.interval = args[0] elif func == "setEvents": req.events = re.compile(args[0], re.I) + elif func == "setInteraction": + req.interaction = args[0] elif func == self.COMMAND: req.mode = Mode.RUNNING diff --git a/module/web/static/css/default/style.less b/module/web/static/css/default/style.less index b3020d30f..f48aff9fd 100644 --- a/module/web/static/css/default/style.less +++ b/module/web/static/css/default/style.less @@ -326,6 +326,7 @@ header .logo { .header-area {
display: none; // hidden by default
position: absolute;
+ bottom: -28px;
line-height: 18px;
top: @header-height;
padding: 4px 10px 6px 10px;
@@ -339,12 +340,19 @@ header .logo { #notification-area {
.header-area;
left: 140px;
+
+ .badge {
+ vertical-align: top;
+ }
+
+ .btn-query, .btn-notification {
+ cursor: pointer;
+ }
}
#selection-area {
.header-area;
left: 50%;
- bottom: -28px;
min-width: 15%;
i {
diff --git a/module/web/static/js/collections/InteractionList.js b/module/web/static/js/collections/InteractionList.js new file mode 100644 index 000000000..88651970e --- /dev/null +++ b/module/web/static/js/collections/InteractionList.js @@ -0,0 +1,47 @@ +define(['jquery', 'backbone', 'underscore', 'app', 'models/InteractionTask'], + function($, Backbone, _, App, InteractionTask) { + + return Backbone.Collection.extend({ + + model: InteractionTask, + + comparator: function(task) { + return task.get('iid'); + }, + + fetch: function(options) { + options = App.apiRequest('getInteractionTasks/0'); + + return Backbone.Collection.prototype.fetch.apply(this, options); + }, + + toJSON: function() { + var data = {queries: 0, notifications: 0, empty: false}; + + this.map(function(task) { + if (task.isNotification()) + data.notifications++; + else + data.queries++; + }); + + if (!data.queries && !data.notifications) + data.empty = true; + + return data; + }, + + // a task is waiting for attention (no notification) + hasTaskWaiting: function() { + var tasks = 0; + this.map(function(task) { + if (!task.isNotification()) + tasks++; + }); + + return tasks > 0; + } + + }); + + });
\ No newline at end of file diff --git a/module/web/static/js/models/InteractionTask.js b/module/web/static/js/models/InteractionTask.js new file mode 100644 index 000000000..4ba88a539 --- /dev/null +++ b/module/web/static/js/models/InteractionTask.js @@ -0,0 +1,27 @@ +define(['jquery', 'backbone', 'underscore', 'utils/apitypes'], + function($, Backbone, _, Api) { + + return Backbone.Model.extend({ + + idAttribute: 'iid', + + defaults: { + iid: -1, + type: null, + input: null, + default_value: null, + title: "", + description: "", + plugin: "" + }, + + // Model Constructor + initialize: function() { + + }, + + isNotification: function() { + return this.get('type') === Api.Interaction.Notification; + } + }); + });
\ No newline at end of file diff --git a/module/web/static/js/utils/apitypes.js b/module/web/static/js/utils/apitypes.js index c9fca48d6..28620250e 100644 --- a/module/web/static/js/utils/apitypes.js +++ b/module/web/static/js/utils/apitypes.js @@ -4,9 +4,9 @@ define([], function() { DownloadState: {'Failed': 3, 'All': 0, 'Unmanaged': 4, 'Finished': 1, 'Unfinished': 2}, DownloadStatus: {'Downloading': 10, 'NA': 0, 'Processing': 14, 'Waiting': 9, 'Decrypting': 13, 'Paused': 4, 'Failed': 7, 'Finished': 5, 'Skipped': 6, 'Unknown': 16, 'Aborted': 12, 'Online': 2, 'TempOffline': 11, 'Offline': 1, 'Custom': 15, 'Starting': 8, 'Queued': 3}, FileStatus: {'Remote': 2, 'Ok': 0, 'Missing': 1}, - Input: {'Multiple': 10, 'Int': 2, 'NA': 0, 'List': 11, 'Bool': 7, 'File': 3, 'Text': 1, 'Table': 12, 'Folder': 4, 'Password': 6, 'Click': 8, 'Select': 9, 'Textbox': 5}, + InputType: {'Multiple': 10, 'Int': 2, 'NA': 0, 'List': 11, 'Bool': 7, 'File': 3, 'Text': 1, 'Table': 12, 'Folder': 4, 'Password': 6, 'Click': 8, 'Select': 9, 'Textbox': 5}, + Interaction: {'Captcha': 2, 'All': 0, 'Query': 4, 'Notification': 1}, MediaType: {'All': 0, 'Audio': 2, 'Image': 4, 'Other': 1, 'Video': 8, 'Document': 16, 'Archive': 32}, - Output: {'Captcha': 2, 'All': 0, 'Query': 4, 'Notification': 1}, PackageStatus: {'Paused': 1, 'Remote': 3, 'Folder': 2, 'Ok': 0}, Permission: {'All': 0, 'Interaction': 32, 'Modify': 4, 'Add': 1, 'Accounts': 16, 'Plugins': 64, 'Download': 8, 'Delete': 2}, Role: {'Admin': 0, 'User': 1}, diff --git a/module/web/static/js/views/headerView.js b/module/web/static/js/views/headerView.js index 9e18734d4..b5b4a9d24 100644 --- a/module/web/static/js/views/headerView.js +++ b/module/web/static/js/views/headerView.js @@ -1,6 +1,6 @@ define(['jquery', 'underscore', 'backbone', 'app', 'models/ServerStatus', 'collections/ProgressList', - 'views/progressView', 'helpers/formatSize', 'flot'], - function($, _, Backbone, App, ServerStatus, ProgressList, ProgressView, formatSize) { + 'views/progressView', 'views/notificationView', 'helpers/formatSize', 'flot'], + function($, _, Backbone, App, ServerStatus, ProgressList, ProgressView, notificationView, formatSize) { // Renders the header with all information return Backbone.View.extend({ @@ -18,7 +18,6 @@ define(['jquery', 'underscore', 'backbone', 'app', 'models/ServerStatus', 'colle // html elements grabber: null, - notifications: null, header: null, progress: null, speedgraph: null, @@ -29,12 +28,15 @@ define(['jquery', 'underscore', 'backbone', 'app', 'models/ServerStatus', 'colle progressList: null, speeds: null, + // sub view + notificationView: null, + // save if last progress was empty wasEmpty: false, initialize: function() { var self = this; - this.notifications = this.$('#notification-area').calculateHeight().height(0); + this.notificationView = new notificationView(); this.status = new ServerStatus(); this.listenTo(this.status, 'change', this.render); @@ -98,7 +100,9 @@ define(['jquery', 'underscore', 'backbone', 'app', 'models/ServerStatus', 'colle // queue/processing size? var status = this.status.toJSON(); - status.maxspeed = _.max(this.speeds, function(speed) {return speed[1];})[1] * 1024; + status.maxspeed = _.max(this.speeds, function(speed) { + return speed[1]; + })[1] * 1024; this.$('.status-block').html( this.templateStatus(status) ); @@ -152,8 +156,8 @@ define(['jquery', 'underscore', 'backbone', 'app', 'models/ServerStatus', 'colle if (data === null) return; if (data['@class'] === "ServerStatus") { + // TODO: load interaction when none available this.status.set(data); - this.speeds = this.speeds.slice(1); this.speeds.push([this.speeds[this.speeds.length - 1][0] + 1, Math.floor(data.speed / 1024)]); @@ -169,7 +173,7 @@ define(['jquery', 'underscore', 'backbone', 'app', 'models/ServerStatus', 'colle else if (data['@class'] === 'EventInfo') this.onEvent(data.eventname, data.event_args); else - console.log('Unknown Async input'); + console.log('Unknown Async input', data); }, diff --git a/module/web/static/js/views/notificationView.js b/module/web/static/js/views/notificationView.js new file mode 100644 index 000000000..22c727304 --- /dev/null +++ b/module/web/static/js/views/notificationView.js @@ -0,0 +1,65 @@ +define(['jquery', 'backbone', 'underscore', 'app', 'collections/InteractionList'], + function($, Backbone, _, App, InteractionList) { + + // Renders context actions for selection packages and files + return Backbone.View.extend({ + el: '#notification-area', + template: _.compile($("#template-notification").html()), + + events: { + 'click .btn-query': 'openQuery', + 'click .btn-notification': 'openNotifications' + }, + + tasks: null, + // current open task + current: null, + // area is slided out + visible: false, + + initialize: function() { + this.tasks = new InteractionList(); + + this.$el.calculateHeight().height(0); + + App.vent.on('interaction:added', _.bind(this.onAdd, this)); + App.vent.on('interaction:deleted', _.bind(this.onDelete, this)); + + var render = _.bind(this.render, this); + this.listenTo(this.tasks, 'add', render); + this.listenTo(this.tasks, 'remove', render); + + }, + + onAdd: function(task) { + this.tasks.add(task); + }, + + onDelete: function(task) { + this.tasks.remove(task); + }, + + render: function() { + this.$el.html(this.template(this.tasks.toJSON())); + + if (this.tasks.length > 0 && !this.visible) { + this.$el.slideOut(); + this.visible = true; + } + else if (this.tasks.length === 0 && this.visible) { + this.$el.slideIn(); + this.visible = false; + } + + return this; + }, + + openQuery: function() { + + }, + + openNotifications: function() { + + } + }); + });
\ No newline at end of file diff --git a/module/web/templates/default/base.html b/module/web/templates/default/base.html index 6c0e7b999..dfcfb9e3a 100644 --- a/module/web/templates/default/base.html +++ b/module/web/templates/default/base.html @@ -92,6 +92,23 @@ </span>
</script>
+ <script type="text/template" id="template-notification">
+ <%= if queries %>
+ <span class="btn-query">
+ Queries <span class="badge badge-info"><% queries %></span>
+ </span>
+ <%/if%>
+ <%= if notifications %>
+ <span class="btn-notification">
+ Notifications <span class="badge badge-success"><% notifications %></span>
+ </span>
+ <%/if%>
+ <%= if empty %>
+ Nothing to show
+ <%/if%>
+ </%if%>
+ </script>
+
{% block head %}
{% endblock %}
</head>
@@ -157,9 +174,7 @@ </div>
{% endif %}
</div>
- <div id="notification-area" style="">
- Notifications
- <span class="badge badge-info">88</span>
+ <div id="notification-area">
</div>
<div id="selection-area">
</div>
diff --git a/tests/manager/test_interactionManager.py b/tests/manager/test_interactionManager.py index db233bb25..0ab7af80d 100644 --- a/tests/manager/test_interactionManager.py +++ b/tests/manager/test_interactionManager.py @@ -1,12 +1,19 @@ # -*- coding: utf-8 -*- from unittest import TestCase + from tests.helper.Stubs import Core -from module.Api import Input, Output +from module.Api import InputType, Interaction from module.interaction.InteractionManager import InteractionManager + class TestInteractionManager(TestCase): + ADMIN = None + USER = 1 + + def assertEmpty(self, list1): + return self.assertListEqual(list1, []) @classmethod def setUpClass(cls): @@ -15,44 +22,60 @@ class TestInteractionManager(TestCase): def setUp(self): self.im = InteractionManager(self.core) + self.assertFalse(self.im.isClientConnected(self.ADMIN)) + self.assertFalse(self.im.isTaskWaiting(self.ADMIN)) + self.assertEmpty(self.im.getTasks(self.ADMIN)) def test_notifications(self): - n = self.im.createNotification("test", "notify") - assert self.im.getNotifications() == [n] + + self.assertTrue(self.im.isTaskWaiting(self.ADMIN)) + self.assertListEqual(self.im.getTasks(self.ADMIN), [n]) + + n.seen = True + self.assertFalse(self.im.isTaskWaiting(self.ADMIN)) for i in range(10): self.im.createNotification("title", "test") - assert len(self.im.getNotifications()) == 11 + self.assertEqual(len(self.im.getTasks(self.ADMIN)), 11) + self.assertFalse(self.im.getTasks(self.USER)) + self.assertFalse(self.im.getTasks(self.ADMIN, Interaction.Query)) def test_captcha(self): - assert self.im.getTask() is None - - t = self.im.newCaptchaTask("1", "", "") - assert t.output == Output.Captcha - self.im.handleTask(t) - assert t is self.im.getTask() + t = self.im.createCaptchaTask("1", "png", "", owner=self.ADMIN) - t2 = self.im.newCaptchaTask("2", "", "") - self.im.handleTask(t2) - - assert self.im.getTask(Output.Query) is None - assert self.im.getTask() is t + self.assertEqual(t.type, Interaction.Captcha) + self.assertListEqual(self.im.getTasks(self.ADMIN), [t]) + self.assertEmpty(self.im.getTasks(self.USER)) + t.setShared() + self.assertListEqual(self.im.getTasks(self.USER), [t]) + t2 = self.im.createCaptchaTask("2", "png", "", owner=self.USER) + self.assertTrue(self.im.isTaskWaiting(self.USER)) + self.assertEmpty(self.im.getTasks(self.USER, Interaction.Query)) self.im.removeTask(t) - assert self.im.getTask() is t2 - self.im.getTaskByID(t2.iid) - assert self.im.getTask() is None + self.assertListEqual(self.im.getTasks(self.ADMIN), [t2]) + self.assertIs(self.im.getTaskByID(t2.iid), t2) def test_query(self): - assert self.im.getTask() is None - t = self.im.newQueryTask(Input.Text, None, "text") - assert t.description == "text" - self.im.handleTask(t) + t = self.im.createQueryTask(InputType.Text, "text", owner=self.ADMIN) + + self.assertEqual(t.description, "text") + self.assertListEqual(self.im.getTasks(self.ADMIN, Interaction.Query), [t]) + self.assertEmpty(self.im.getTasks(Interaction.Captcha)) + + + def test_clients(self): + self.im.getTasks(self.ADMIN, Interaction.Captcha) + + self.assertTrue(self.im.isClientConnected(self.ADMIN)) + self.assertFalse(self.im.isClientConnected(self.USER)) + - assert self.im.getTask(Output.Query) is t - assert self.im.getTask(Output.Captcha) is None
\ No newline at end of file + def test_users(self): + t = self.im.createCaptchaTask("1", "png", "", owner=self.USER) + self.assertListEqual(self.im.getTasks(self.ADMIN), [t]) |