notification.py 7.01 KB
Newer Older
Martin Matějek's avatar
Martin Matějek committed
1
import json
2
import logging
3
import uuid
4

Martin Matějek's avatar
Martin Matějek committed
5
from datetime import datetime
6
from jinja2 import TemplateError
7
from types import SimpleNamespace
8

9
from .config import config
10
from .exceptions import CreateNotificationError, NotificationTemplatingError
11
from .notificationskeleton import NotificationSkeleton
12
from .supervisor import Supervisor
Martin Matějek's avatar
Martin Matějek committed
13

14 15
logger = logging.getLogger(__name__)

Martin Matějek's avatar
Martin Matějek committed
16 17

class Notification:
18
    ATTRS = ['notif_id', 'timestamp', 'skeleton', 'persistent', 'timeout', 'severity', 'data', 'fallback', 'valid', 'explicit_dismiss', 'default_action']
19
    # TODO: better name?
20
    META_ATTRS = ['persistent', 'timestamp', 'severity', 'default_action']
21

22
    def __init__(self, notif_id, timestamp, skeleton, data, persistent, timeout, severity, fallback=None, valid=True, explicit_dismiss=True, default_action='dismiss'):
23
        self.notif_id = notif_id
Martin Matějek's avatar
Martin Matějek committed
24
        self.timestamp = timestamp
25

26
        self.skeleton = skeleton
27

28
        self.fallback = fallback
29
        self.persistent = persistent
Martin Matějek's avatar
Martin Matějek committed
30
        self.timeout = timeout
31
        self.severity = severity
32
        self.explicit_dismiss = explicit_dismiss
Martin Matějek's avatar
Martin Matějek committed
33

34 35 36 37 38 39 40
        # default "closing" action
        # users will probably want to use "default" as simplest method to "just dismiss" notification
        if default_action in self.skeleton.actions:
            self.default_action = default_action
        else:
            self.default_action = 'dismiss'

41 42
        self.valid = valid

43
        self.data = data
Martin Matějek's avatar
Martin Matějek committed
44

45
        if not self.fallback:
Martin Matějek's avatar
Martin Matějek committed
46
            self.fallback = self.render_fallback_data()
47

Martin Matějek's avatar
Martin Matějek committed
48
    @classmethod
49
    def new(cls, skel, **opts):
50
        """Generate mandatory params during creation and return new instance"""
51
        nid = cls._generate_id()
Martin Matějek's avatar
Martin Matějek committed
52
        ts = int(datetime.utcnow().timestamp())
53

54
        n = cls(nid, ts, skel, **opts)
55 56 57 58

        return n

    @classmethod
59
    def from_file(cls, path, plugin_storage):
60
        """Load notification from it's file and return new instance"""
Martin Matějek's avatar
Martin Matějek committed
61
        try:
62
            with open(path, 'r') as f:
63
                json_data = json.load(f)
64 65 66 67 68 69
        except FileNotFoundError:
            logger.warning("Failed to open notification file '%s'", path)
            return None
        except json.JSONDecodeError as e:
            logger.warning("Failed to deserialize json file: %s", e)
            return None
70

71 72 73 74 75 76
        skel_args = json_data['skeleton']
        plug = plugin_storage.get_plugin(skel_args['plugin_name'])

        skel_args['jinja_env'] = plug.get_jinja_env()

        skel_obj = NotificationSkeleton(**skel_args)
77
        json_data['skeleton'] = skel_obj  # replace json data with skeleton instance
78

79
        return cls(**json_data)
80

81 82
    def is_valid(self, timestamp=None):
        """If notification is still valid based on multiple conditions"""
83 84 85
        if not timestamp:
            timestamp = int(datetime.utcnow().timestamp())

86 87 88
        if not self.valid:
            return False

Martin Matějek's avatar
Martin Matějek committed
89
        if self.timeout:
90 91
            create_time = datetime.fromtimestamp(self.timestamp)
            delta = timestamp - create_time
Martin Matějek's avatar
Martin Matějek committed
92

93 94
            if delta.total_seconds() >= self.timeout:
                return False
Martin Matějek's avatar
Martin Matějek committed
95 96

        return True
97

98 99
    def render_template(self, media_type, lang):
        try:
100
            return self.skeleton.render(self.data, media_type, lang)
101 102 103
        except TemplateError:
            raise NotificationTemplatingError("Failed to render template")

104 105
    def render(self, media_type, lang):
        """Return rendered template as given media type and in given language"""
106
        try:
107 108 109
            output = {}
            output['message'] = self.render_template(media_type, lang)
            output['actions'] = self.skeleton.translate_actions(lang)
110 111
            if self.explicit_dismiss:
                output['actions']['dismiss'] = ''  # default action for all notifications
112
            output['metadata'] = self._serialize_data(self.META_ATTRS)
113 114

            return output
115 116
        except NotificationTemplatingError:
            return self.fallback[media_type]
117

Martin Matějek's avatar
Martin Matějek committed
118
    def render_fallback_data(self):
119 120
        """Render all media types in default languages"""
        ret = {}
121
        try:
122

123 124 125
            for mt in self.skeleton.get_media_types():
                # render in default lang -> en
                ret[mt] = self.render_template(mt, 'en')
Martin Matějek's avatar
Martin Matějek committed
126

Martin Matějek's avatar
Martin Matějek committed
127
        except NotificationTemplatingError:
128
            raise CreateNotificationError("Couldn't create notification with given template variables")
129

130
        return ret
131

132
    def _serialize_data(self, attrs):
133
        """Return serialized attributes of instance in dictionary"""
134
        out = {}
135

136
        for attr in attrs:
137 138 139 140
            data = getattr(self, attr)
            if hasattr(data, 'serialize'):
                data = data.serialize()

141
            out[attr] = data
142

143
        return out
144

145 146
    def serialize(self):
        """Return serialized data as json"""
147
        return json.dumps(self._serialize_data(self.ATTRS), indent=4)
148 149 150

    def get_data(self):
        """Return instance content as SimpleNamespace"""
151
        return SimpleNamespace(**self._serialize_data(self.ATTRS))
152

153 154 155 156 157 158 159 160
    def _dismiss(self):
        """
        Internal dismiss function

        Mark notification for deletion
        """
        self.valid = False

161
    def dismiss(self):
162
        """Dismiss notification if explicit dismiss is allowed"""
163 164 165
        if not self.explicit_dismiss:
            return None

166
        self._dismiss()
167
        return True
168

169
    def _run_cmd_standalone(self, cmd, cmd_args, timeout):
170 171 172 173 174 175
        """
        Run command in new standalone process

        Process is supervised to control it's run a little bit
        """
        supervisor = Supervisor()
176
        supervisor.run(cmd, cmd_args, timeout)
177

178 179
    def call_action(self, name, plugin_skeleton, cmd_args=None, dry_run=True):
        action_cmd = self.skeleton.get_action_cmd(name)
180 181 182

        if not action_cmd:
            logger.debug("Action '%s' not available", name)
183
            return False
184 185

        if dry_run:
186
            logger.debug("Dry run: executing command '%s'", action_cmd)
187
        else:
188 189 190 191 192 193 194
            if name not in plugin_skeleton.actions.keys():
                logger.warning("Action is not presented in current version of plugin. Won't execute")
            elif self.skeleton.version != plugin_skeleton.version:
                logger.warning("Using outdated action. Won't execute")
            else:
                timeout = config.getint('settings', 'cmd_timeout')
                self._run_cmd_standalone(action_cmd, cmd_args, timeout)
Martin Matějek's avatar
Martin Matějek committed
195

196
        self._dismiss()
197 198 199 200
        return True

    def get_default_action(self):
        return self.default_action
201

202 203 204
    def has_media_type(self, media_type):
        return media_type in self.skeleton.get_media_types()

205 206
    @staticmethod
    def _generate_id():
207
        """
208
        Generate unique id of message based on uuid
Martin Matějek's avatar
Martin Matějek committed
209

210
        Returned as string
211
        """
212
        return uuid.uuid4().hex
213 214 215

    def __str__(self):
        out = "{\n"
216 217 218 219 220 221 222 223

        for attr in self.ATTRS:
            data = getattr(self, attr)
            if hasattr(data, 'serialize'):
                data = data.serialize()

            out += "\t{}: {}\n".format(attr, data)

224 225 226
        out += "}\n"

        return out