source: tailbone/tailbone/views/upgrades.py @ eafe373

Last change on this file since eafe373 was eafe373, checked in by Lance Edgar <ledgar@…>, 12 months ago

Refactor template content_title() and prev/next buttons feature

those were intertwined but now are a bit more separate, much better

  • Property mode set to 100644
File size: 14.0 KB
Line 
1# -*- coding: utf-8; -*-
2################################################################################
3#
4#  Rattail -- Retail Software Framework
5#  Copyright © 2010-2019 Lance Edgar
6#
7#  This file is part of Rattail.
8#
9#  Rattail is free software: you can redistribute it and/or modify it under the
10#  terms of the GNU General Public License as published by the Free Software
11#  Foundation, either version 3 of the License, or (at your option) any later
12#  version.
13#
14#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
15#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
16#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
17#  details.
18#
19#  You should have received a copy of the GNU General Public License along with
20#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Views for app upgrades
25"""
26
27from __future__ import unicode_literals, absolute_import
28
29import os
30import re
31import logging
32
33import six
34from sqlalchemy import orm
35
36# TODO: pip has declared these to be "not public API" so we should find another way..
37try:
38    # this works for now, with pip 10.0.1
39    from pip._internal.download import PipSession
40    from pip._internal.req import parse_requirements
41except ImportError:
42    # this should work with pip < 10.0
43    from pip.download import PipSession
44    from pip.req import parse_requirements
45
46from rattail.db import model, Session as RattailSession
47from rattail.time import make_utc
48from rattail.threads import Thread
49from rattail.upgrades import get_upgrade_handler
50
51from deform import widget as dfwidget
52from webhelpers2.html import tags, HTML
53
54from tailbone.views import MasterView
55from tailbone.progress import SessionProgress, get_progress_session
56
57
58log = logging.getLogger(__name__)
59
60
61class UpgradeView(MasterView):
62    """
63    Master view for all user events
64    """
65    model_class = model.Upgrade
66    downloadable = True
67    cloneable = True
68    executable = True
69    execute_progress_template = '/upgrade.mako'
70    execute_progress_initial_msg = "Upgrading"
71
72    labels = {
73        'executed_by': "Executed by",
74        'status_code': "Status",
75        'stdout_file': "STDOUT",
76        'stderr_file': "STDERR",
77    }
78
79    grid_columns = [
80        'created',
81        'description',
82        # 'not_until',
83        'enabled',
84        'status_code',
85        'executed',
86        'executed_by',
87    ]
88
89    form_fields = [
90        'description',
91        # 'not_until',
92        # 'requirements',
93        'notes',
94        'created',
95        'created_by',
96        'enabled',
97        'executing',
98        'executed',
99        'executed_by',
100        'status_code',
101        'stdout_file',
102        'stderr_file',
103        'exit_code',
104        'package_diff',
105    ]
106
107    def __init__(self, request):
108        super(UpgradeView, self).__init__(request)
109        self.handler = self.get_handler()
110
111    def get_handler(self):
112        """
113        Returns the ``UpgradeHandler`` instance for the view.  The handler
114        factory for this may be defined by config, e.g.:
115
116        .. code-block:: ini
117
118           [rattail.upgrades]
119           handler = myapp.upgrades:CustomUpgradeHandler
120        """
121        return get_upgrade_handler(self.rattail_config)
122
123    def configure_grid(self, g):
124        super(UpgradeView, self).configure_grid(g)
125        g.set_joiner('executed_by', lambda q: q.join(model.User, model.User.uuid == model.Upgrade.executed_by_uuid).outerjoin(model.Person))
126        g.set_sorter('executed_by', model.Person.display_name)
127        g.set_enum('status_code', self.enum.UPGRADE_STATUS)
128        g.set_type('created', 'datetime')
129        g.set_type('executed', 'datetime')
130        g.set_sort_defaults('created', 'desc')
131        g.set_link('created')
132        g.set_link('description')
133        # g.set_link('not_until')
134        g.set_link('executed')
135
136    def grid_extra_class(self, upgrade, i):
137        if upgrade.status_code == self.enum.UPGRADE_STATUS_FAILED:
138            return 'warning'
139        if upgrade.status_code == self.enum.UPGRADE_STATUS_EXECUTING:
140            return 'notice'
141
142    def template_kwargs_view(self, **kwargs):
143        upgrade = kwargs['instance']
144
145        kwargs['show_prev_next'] = True
146        kwargs['prev_url'] = None
147        kwargs['next_url'] = None
148
149        upgrades = self.Session.query(model.Upgrade)\
150                               .filter(model.Upgrade.uuid != upgrade.uuid)
151        older = upgrades.filter(model.Upgrade.created <= upgrade.created)\
152                        .order_by(model.Upgrade.created.desc())\
153                        .first()
154        newer = upgrades.filter(model.Upgrade.created >= upgrade.created)\
155                        .order_by(model.Upgrade.created)\
156                        .first()
157
158        if older:
159            kwargs['prev_url'] = self.get_action_url('view', older)
160        if newer:
161            kwargs['next_url'] = self.get_action_url('view', newer)
162
163        return kwargs
164
165    def configure_form(self, f):
166        super(UpgradeView, self).configure_form(f)
167
168        # status_code
169        if self.creating:
170            f.remove_field('status_code')
171        else:
172            f.set_enum('status_code', self.enum.UPGRADE_STATUS)
173            # f.set_readonly('status_code')
174
175        # executing
176        if not self.editing:
177            f.remove('executing')
178
179        f.set_type('created', 'datetime')
180        f.set_type('enabled', 'boolean')
181        f.set_type('executed', 'datetime')
182        # f.set_widget('not_until', dfwidget.DateInputWidget())
183        f.set_widget('notes', dfwidget.TextAreaWidget(cols=80, rows=8))
184        f.set_renderer('stdout_file', self.render_stdout_file)
185        f.set_renderer('stderr_file', self.render_stdout_file)
186        f.set_renderer('package_diff', self.render_package_diff)
187        # f.set_readonly('created')
188        # f.set_readonly('created_by')
189        f.set_readonly('executed')
190        f.set_readonly('executed_by')
191        upgrade = f.model_instance
192        if self.creating or self.editing:
193            f.remove_field('created')
194            f.remove_field('created_by')
195            f.remove_field('stdout_file')
196            f.remove_field('stderr_file')
197            if self.creating or not upgrade.executed:
198                f.remove_field('executed')
199                f.remove_field('executed_by')
200            if self.editing and upgrade.executed:
201                f.remove_field('enabled')
202
203        elif f.model_instance.executed:
204            f.remove_field('enabled')
205
206        else:
207            f.remove_field('executed')
208            f.remove_field('executed_by')
209            f.remove_field('stdout_file')
210            f.remove_field('stderr_file')
211
212        if not self.viewing or not upgrade.executed:
213            f.remove_field('package_diff')
214            f.remove_field('exit_code')
215
216    def configure_clone_form(self, f):
217        f.fields = ['description', 'notes', 'enabled']
218
219    def clone_instance(self, original):
220        cloned = self.model_class()
221        cloned.created = make_utc()
222        cloned.created_by = self.request.user
223        cloned.description = original.description
224        cloned.notes = original.notes
225        cloned.status_code = self.enum.UPGRADE_STATUS_PENDING
226        cloned.enabled = original.enabled
227        self.Session.add(cloned)
228        self.Session.flush()
229        return cloned
230
231    def render_stdout_file(self, upgrade, fieldname):
232        if fieldname.startswith('stderr'):
233            filename = 'stderr.log'
234        else:
235            filename = 'stdout.log'
236        path = self.rattail_config.upgrade_filepath(upgrade.uuid, filename=filename)
237        if path:
238            url = '{}?filename={}'.format(self.get_action_url('download', upgrade), filename)
239            return self.render_file_field(path, url, filename=filename)
240        return filename
241
242    def render_package_diff(self, upgrade, fieldname):
243        try:
244            before = self.parse_requirements(upgrade, 'before')
245            after = self.parse_requirements(upgrade, 'after')
246            diff = self.make_diff(before, after,
247                                  columns=["package", "old version", "new version"],
248                                  render_field=self.render_diff_field,
249                                  render_value=self.render_diff_value,
250            )
251            showing = HTML.tag('div',
252                               "showing: "
253                               + tags.link_to("all", '#', class_='all')
254                               + " / "
255                               + tags.link_to("diffs only", '#', class_='diffs'),
256                               class_='showing')
257            return showing + diff.render_html()
258        except:
259            log.debug("failed to render package diff for upgrade: {}".format(upgrade), exc_info=True)
260            return "(not available for this upgrade)"
261
262    def changelog_link(self, project, url):
263        return tags.link_to(project, url, target='_blank')
264
265    commit_hash_pattern = re.compile(r'^.{40}$')
266
267    def get_changelog_url(self, project, old_version, new_version):
268        projects = {
269            'rattail': 'rattail',
270            'rattail-tempmon': 'rattail-tempmon',
271            'Tailbone': 'tailbone',
272        }
273        if project not in projects:
274            return
275        if self.commit_hash_pattern.match(new_version):
276            if new_version == old_version:
277                return 'https://rattailproject.org/trac/log/{}/?rev={}&limit=100'.format(
278                    projects[project], new_version)
279            else:
280                return 'https://rattailproject.org/trac/log/{}/?rev={}&stop_rev={}&limit=100'.format(
281                    projects[project], new_version, old_version)
282        elif re.match(r'^\d+\.\d+\.\d+$', new_version):
283            return 'https://rattailproject.org/trac/browser/{}/CHANGES.rst?rev=v{}'.format(
284                projects[project], new_version)
285        else:
286            return 'https://rattailproject.org/trac/browser/{}/CHANGES.rst'.format(
287                projects[project])
288
289    def render_diff_field(self, field, diff):
290        old_version = diff.old_value(field)
291        new_version = diff.new_value(field)
292        url = self.get_changelog_url(field, old_version, new_version)
293        if url:
294            return self.changelog_link(field, url)
295        return field
296
297    def render_diff_value(self, field, value):
298        if value is None:
299            return ""
300        if value.startswith("u'") and value.endswith("'"):
301            return value[2:1]
302        return value
303
304    def parse_requirements(self, upgrade, type_):
305        packages = {}
306        path = self.rattail_config.upgrade_filepath(upgrade.uuid, filename='requirements.{}.txt'.format(type_))
307        session = PipSession()
308        for req in parse_requirements(path, session=session):
309            version = self.version_from_requirement(req)
310            packages[req.name] = version
311        return packages
312
313    def version_from_requirement(self, req):
314        if req.specifier:
315            match = re.match(r'^==(.*)$', six.text_type(req.specifier))
316            if match:
317                return match.group(1)
318            return six.text_type(req.specifier)
319        elif req.link:
320            match = re.match(r'^.*@(.*)#egg=.*$', six.text_type(req.link))
321            if match:
322                return match.group(1)
323            return six.text_type(req.link)
324        return ""
325
326    def download_path(self, upgrade, filename):
327        return self.rattail_config.upgrade_filepath(upgrade.uuid, filename=filename)
328
329    def download_content_type(self, path, filename):
330        return 'text/plain'
331
332    def before_create_flush(self, upgrade, form):
333        upgrade.created_by = self.request.user
334        upgrade.status_code = self.enum.UPGRADE_STATUS_PENDING
335
336    # TODO: this was an attempt to make the progress bar survive Apache restart,
337    # but it didn't work...  need to "fork" instead of waiting for execution?
338    # def make_execute_progress(self):
339    #     key = '{}.execute'.format(self.get_grid_key())
340    #     return SessionProgress(self.request, key, session_type='file')
341
342    def execute_instance(self, upgrade, user, **kwargs):
343        session = orm.object_session(upgrade)
344        self.handler.mark_executing(upgrade)
345        session.commit()
346        self.handler.do_execute(upgrade, user, **kwargs)
347
348    def execute_progress(self):
349        upgrade = self.get_instance()
350        key = '{}.execute'.format(self.get_grid_key())
351        session = get_progress_session(self.request, key)
352        if session.get('complete'):
353            msg = session.get('success_msg')
354            if msg:
355                self.request.session.flash(msg)
356        elif session.get('error'):
357            self.request.session.flash(session.get('error_msg', "An unspecified error occurred."), 'error')
358        data = dict(session)
359
360        path = self.rattail_config.upgrade_filepath(upgrade.uuid, filename='stdout.log')
361        offset = session.get('stdout.offset', 0)
362        if os.path.exists(path):
363            size = os.path.getsize(path) - offset
364            if size > 0:
365                with open(path, 'rb') as f:
366                    f.seek(offset)
367                    chunk = f.read(size)
368                    data['stdout'] = chunk.decode('utf8').replace('\n', '<br />')
369                session['stdout.offset'] = offset + size
370                session.save()
371
372        return data
373
374    def delete_instance(self, upgrade):
375        self.handler.delete_files(upgrade)
376        super(UpgradeView, self).delete_instance(upgrade)
377
378    @classmethod
379    def defaults(cls, config):
380        route_prefix = cls.get_route_prefix()
381        url_prefix = cls.get_url_prefix()
382        permission_prefix = cls.get_permission_prefix()
383        model_key = cls.get_model_key()
384
385        # execution progress
386        config.add_route('{}.execute_progress'.format(route_prefix), '{}/{{{}}}/execute/progress'.format(url_prefix, model_key))
387        config.add_view(cls, attr='execute_progress', route_name='{}.execute_progress'.format(route_prefix),
388                        permission='{}.execute'.format(permission_prefix), renderer='json')
389
390        cls._defaults(config)
391
392
393def includeme(config):
394    UpgradeView.defaults(config)
Note: See TracBrowser for help on using the repository browser.