1import enum
2import itertools
3from datetime import datetime
4import platform
5import json
6import logging
7import os
8from typing import List
9
10from AnyQt.QtCore import QCoreApplication, QSettings
11
12from orangecanvas import config
13from orangecanvas.scheme import SchemeNode, SchemeLink, Scheme
14
15log = logging.getLogger(__name__)
16
17
18class EventType(enum.IntEnum):
19    NodeAdd = 0
20    NodeRemove = 1
21    LinkAdd = 2
22    LinkRemove = 3
23
24
25class ActionType(enum.IntEnum):
26    Unclassified = 0
27    ToolboxClick = 1
28    ToolboxDrag = 2
29    QuickMenu = 3
30    ExtendFromSource = 4
31    ExtendFromSink = 5
32    InsertDrag = 6
33    InsertMenu = 7
34    Undo = 8
35    Redo = 9
36    Duplicate = 10
37    Load = 11
38
39
40class UsageStatistics:
41    """
42    Tracks usage statistics if enabled (is disabled by default).
43
44    Data is tracked and stored in application data directory in
45    'usage-statistics.json' file.
46
47    It is the application's responsibility to ask for permission and
48    appropriately handle the collected statistics.
49
50    Data tracked per canvas session:
51        date,
52        application version,
53        operating system,
54        anaconda boolean,
55        UUID (in Orange3),
56        a sequence of actions of type ActionType
57
58    An action consists of one or more events of type EventType.
59    Events refer to nodes according to a unique integer ID.
60    Each node is also associated with a widget name, assigned in a NodeAdd event.
61    Link events also reference corresponding source/sink channel names.
62
63    Some actions carry metadata (e.g. search query for QuickMenu, Extend).
64
65    Parameters
66    ----------
67    parent: SchemeEditWidget
68    """
69    _is_enabled = False
70    statistics_sessions = []
71    last_search_query = None
72    source_open = False
73    sink_open = False
74
75    Unclassified, ToolboxClick, ToolboxDrag, QuickMenu, ExtendFromSink, ExtendFromSource, \
76    InsertDrag, InsertMenu, Undo, Redo, Duplicate, Load \
77        = list(ActionType)
78
79    def __init__(self, parent):
80        self.parent = parent
81
82        self._actions = []
83        self._events = []
84        self._widget_ids = {}
85        self._id_iter = itertools.count()
86
87        self._action_type = ActionType.Unclassified
88        self._metadata = None
89
90        UsageStatistics.statistics_sessions.append(self)
91
92    @classmethod
93    def is_enabled(cls) -> bool:
94        """
95        Returns
96        -------
97        enabled : bool
98            Is usage collection enabled.
99        """
100        return cls._is_enabled
101
102    @classmethod
103    def set_enabled(cls, state: bool) -> None:
104        """
105        Enable/disable usage collection.
106
107        Parameters
108        ----------
109        state : bool
110        """
111        if cls._is_enabled == state:
112            return
113
114        cls._is_enabled = state
115        log.info("{} usage statistics tracking".format(
116            "Enabling" if state else "Disabling"
117        ))
118        for session in UsageStatistics.statistics_sessions:
119            if state:
120                # log current scheme state after enabling of statistics
121                scheme = session.parent.scheme()
122                session.log_scheme(scheme)
123            else:
124                session.drop_statistics()
125
126    def begin_action(self, action_type):
127        """
128        Sets the type of action that will be logged upon next call to a log method.
129
130        Each call to begin_action() should be matched with a call to end_action().
131
132        Parameters
133        ----------
134        action_type : ActionType
135        """
136        if not self.is_enabled():
137            return
138
139        if self._action_type != self.Unclassified:
140            raise ValueError("Tried to set " + str(action_type) + \
141                             " but " + str(self._action_type) + " was already set.")
142
143        self._prepare_action(action_type)
144
145    def begin_extend_action(self, from_sink, extended_widget):
146        """
147        Sets the type of action to widget extension in the specified direction,
148        noting the extended widget and query.
149
150        Each call to begin_extend_action() should be matched with a call to end_action().
151
152        Parameters
153        ----------
154        from_sink : bool
155        extended_widget : SchemeNode
156        """
157        if not self.is_enabled():
158            return
159
160        if self._events:
161            log.error("Tried to start extend action while current action already has events")
162            return
163
164        # set action type
165        if from_sink:
166            action_type = ActionType.ExtendFromSink
167        else:
168            action_type = ActionType.ExtendFromSource
169
170        # set metadata
171        if extended_widget not in self._widget_ids:
172            log.error("Attempted to extend widget before it was logged. No action type was set.")
173            return
174        extended_id = self._widget_ids[extended_widget]
175
176        metadata = {"Extended Widget": extended_id}
177
178        self._prepare_action(action_type, metadata)
179
180    def begin_insert_action(self, via_drag, original_link):
181        """
182        Sets the type of action to widget insertion via the specified way,
183        noting the old link's source and sink widgets.
184
185        Each call to begin_insert_action() should be matched with a call to end_action().
186
187        Parameters
188        ----------
189        via_drag : bool
190        original_link : SchemeLink
191        """
192        if not self.is_enabled():
193            return
194
195        if self._events:
196            log.error("Tried to start insert action while current action already has events")
197            return
198
199        source_widget = original_link.source_node
200        sink_widget = original_link.sink_node
201
202        # set action type
203        if via_drag:
204            action_type = ActionType.InsertDrag
205        else:
206            action_type = ActionType.InsertMenu
207
208        # set metadata
209        if source_widget not in self._widget_ids or sink_widget not in self._widget_ids:
210            log.error("Attempted to log insert action between unknown widgets. "
211                      "No action was logged.")
212            self._clear_action()
213            return
214        src_id, sink_id = self._widget_ids[source_widget], self._widget_ids[sink_widget]
215
216        metadata = {"Source Widget": src_id,
217                    "Sink Widget": sink_id}
218
219        self._prepare_action(action_type, metadata)
220
221    def _prepare_action(self, action_type, metadata=None):
222        """
223        Sets the type of action and metadata that will be logged upon next call to a log method.
224
225        Parameters
226        ----------
227        action_type : ActionType
228        metadata : Dict[str, Any]
229        """
230        self._action_type = action_type
231        self._metadata = metadata
232
233    def end_action(self):
234        """
235        Ends the started action, concatenating the relevant events and adding it to
236        the list of actions.
237        """
238        if not self.is_enabled():
239            return
240
241        if not self._events:
242            log.info("End action called but no events were logged.")
243            self._clear_action()
244            return
245
246        action = {
247            "Type": self._action_type,
248            "Events": self._events
249        }
250
251        # add metadata
252        if self._metadata:
253            action.update(self._metadata)
254
255        # add search query if relevant
256        if self._action_type in {ActionType.ExtendFromSource, ActionType.ExtendFromSink,
257                                 ActionType.QuickMenu}:
258            action["Query"] = self.last_search_query
259
260        self._actions.append(action)
261        self._clear_action()
262
263    def _clear_action(self):
264        """
265        Clear the current action.
266        """
267        self._events = []
268        self._action_type = ActionType.Unclassified
269        self._metadata = None
270        self.last_search_query = ""
271
272    def log_node_add(self, widget):
273        """
274        Logs an node addition action, based on the currently set action type.
275
276        Parameters
277        ----------
278        widget : SchemeNode
279        """
280        if not self.is_enabled():
281            return
282
283        # get or generate id for widget
284        if widget in self._widget_ids:
285            widget_id = self._widget_ids[widget]
286        else:
287            widget_id = next(self._id_iter)
288            self._widget_ids[widget] = widget_id
289
290        event = {
291            "Type": EventType.NodeAdd,
292            "Widget Name": widget.description.id,
293            "Widget": widget_id
294        }
295
296        self._events.append(event)
297
298    def log_node_remove(self, widget):
299        """
300        Logs an node removal action.
301
302        Parameters
303        ----------
304        widget : SchemeNode
305        """
306        if not self.is_enabled():
307            return
308
309        # get id for widget
310        if widget not in self._widget_ids:
311            log.error("Attempted to log node removal before its addition. No action was logged.")
312            self._clear_action()
313            return
314        widget_id = self._widget_ids[widget]
315
316        event = {
317            "Type": EventType.NodeRemove,
318            "Widget": widget_id
319        }
320
321        self._events.append(event)
322
323    def log_link_add(self, link):
324        """
325        Logs a link addition action.
326
327        Parameters
328        ----------
329        link : SchemeLink
330        """
331        if not self.is_enabled():
332            return
333
334        self._log_link(EventType.LinkAdd, link)
335
336    def log_link_remove(self, link):
337        """
338        Logs a link removal action.
339
340        Parameters
341        ----------
342        link : SchemeLink
343        """
344        if not self.is_enabled():
345            return
346
347        self._log_link(EventType.LinkRemove, link)
348
349    def _log_link(self, action_type, link):
350        source_widget = link.source_node
351        sink_widget = link.sink_node
352
353        # get id for widgets
354        if source_widget not in self._widget_ids or sink_widget not in self._widget_ids:
355            log.error("Attempted to log link action between unknown widgets. No action was logged.")
356            self._clear_action()
357            return
358
359        src_id, sink_id = self._widget_ids[source_widget], self._widget_ids[sink_widget]
360
361        event = {
362            "Type": action_type,
363            "Source Widget": src_id,
364            "Sink Widget": sink_id,
365            "Source Channel": link.source_channel.name,
366            "Sink Channel": link.sink_channel.name,
367            "Source Open": UsageStatistics.source_open,
368            "Sink Open:": UsageStatistics.sink_open,
369        }
370
371        self._events.append(event)
372
373    def log_scheme(self, scheme):
374        """
375        Log all nodes and links in a scheme.
376
377        Parameters
378        ----------
379        scheme : Scheme
380        """
381        if not self.is_enabled():
382            return
383
384        if not scheme or not scheme.nodes:
385            return
386
387        self.begin_action(ActionType.Load)
388
389        # first log nodes
390        for node in scheme.nodes:
391            self.log_node_add(node)
392
393        # then log links
394        for link in scheme.links:
395            self.log_link_add(link)
396
397        self.end_action()
398
399    def drop_statistics(self):
400        """
401        Clear all data in the statistics session.
402        """
403        self._actions = []
404        self._widget_ids = {}
405        self._id_iter = itertools.count()
406
407    def write_statistics(self):
408        """
409        Write the statistics session to file, and clear it.
410        """
411        if not self.is_enabled():
412            return
413
414        statistics_path = self.filename()
415        statistics = {
416            "Date": str(datetime.now().date()),
417            "Application Version": QCoreApplication.applicationVersion(),
418            "Operating System": platform.system() + " " + platform.release(),
419            "Launch Count": QSettings().value('startup/launch-count', 0, type=int),
420            "Session": self._actions
421        }
422
423        if os.path.isfile(statistics_path):
424            with open(statistics_path) as f:
425                data = json.load(f)
426        else:
427            data = []
428
429        data.append(statistics)
430
431        with open(statistics_path, 'w') as f:
432            json.dump(data, f)
433
434        self.drop_statistics()
435
436    def close(self):
437        """
438        Close statistics session, effectively not updating it upon
439        toggling statistics tracking.
440        """
441        UsageStatistics.statistics_sessions.remove(self)
442
443    @staticmethod
444    def set_last_search_query(query):
445        if not UsageStatistics.is_enabled():
446            return
447
448        UsageStatistics.last_search_query = query
449
450    @staticmethod
451    def set_source_anchor_open(is_open):
452        if not UsageStatistics.is_enabled():
453            return
454
455        UsageStatistics.source_open = is_open
456
457    @staticmethod
458    def set_sink_anchor_open(is_open):
459        if not UsageStatistics.is_enabled():
460            return
461
462        UsageStatistics.sink_open = is_open
463
464    @staticmethod
465    def filename() -> str:
466        """
467        Return the filename path where the statistics are saved
468        """
469        return os.path.join(config.data_dir(), "usage-statistics.json")
470
471    @staticmethod
472    def load() -> 'List[dict]':
473        """
474        Load and return the usage statistics data.
475
476        Returns
477        -------
478        data : dict
479        """
480        if not UsageStatistics.is_enabled():
481            return []
482        try:
483            with open(UsageStatistics.filename(), "r", encoding="utf-8") as f:
484                return json.load(f)
485        except (FileNotFoundError, PermissionError, IsADirectoryError,
486                UnicodeDecodeError, json.JSONDecodeError):
487            return []
488