source: tailbone/tailbone/views/upgrades.py @ 34a3aa0

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

Add smarts for a couple more projects in the upgraded packages links

  • Property mode set to 100644
File size: 14.1 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            'Tailbone': 'tailbone',
271            'rattail-catapult': 'rattail-catapult',
272            'rattail-tempmon': 'rattail-tempmon',
273            'tailbone-catapult': 'tailbone-catapult',
274        }
275        if project not in projects:
276            return
277        if self.commit_hash_pattern.match(new_version):
278            if new_version == old_version:
279                return 'https://rattailproject.org/trac/log/{}/?rev={}&limit=100'.format(
280                    projects[project], new_version)
281            else:
282                return 'https://rattailproject.org/trac/log/{}/?rev={}&stop_rev={}&limit=100'.format(
283                    projects[project], new_version, old_version)
284        elif re.match(r'^\d+\.\d+\.\d+$', new_version):
285            return 'https://rattailproject.org/trac/browser/{}/CHANGES.rst?rev=v{}'.format(
286                projects[project], new_version)
287        else:
288            return 'https://rattailproject.org/trac/browser/{}/CHANGES.rst'.format(
289                projects[project])
290
291    def render_diff_field(self, field, diff):
292        old_version = diff.old_value(field)
293        new_version = diff.new_value(field)
294        url = self.get_changelog_url(field, old_version, new_version)
295        if url:
296            return self.changelog_link(field, url)
297        return field
298
299    def render_diff_value(self, field, value):
300        if value is None:
301            return ""
302        if value.startswith("u'") and value.endswith("'"):
303            return value[2:1]
304        return value
305
306    def parse_requirements(self, upgrade, type_):
307        packages = {}
308        path = self.rattail_config.upgrade_filepath(upgrade.uuid, filename='requirements.{}.txt'.format(type_))
309        session = PipSession()
310        for req in parse_requirements(path, session=session):
311            version = self.version_from_requirement(req)
312            packages[req.name] = version
313        return packages
314
315    def version_from_requirement(self, req):
316        if req.specifier:
317            match = re.match(r'^==(.*)$', six.text_type(req.specifier))
318            if match:
319                return match.group(1)
320            return six.text_type(req.specifier)
321        elif req.link:
322            match = re.match(r'^.*@(.*)#egg=.*$', six.text_type(req.link))
323            if match:
324                return match.group(1)
325            return six.text_type(req.link)
326        return ""
327
328    def download_path(self, upgrade, filename):
329        return self.rattail_config.upgrade_filepath(upgrade.uuid, filename=filename)
330
331    def download_content_type(self, path, filename):
332        return 'text/plain'
333
334    def before_create_flush(self, upgrade, form):
335        upgrade.created_by = self.request.user
336        upgrade.status_code = self.enum.UPGRADE_STATUS_PENDING
337
338    # TODO: this was an attempt to make the progress bar survive Apache restart,
339    # but it didn't work...  need to "fork" instead of waiting for execution?
340    # def make_execute_progress(self):
341    #     key = '{}.execute'.format(self.get_grid_key())
342    #     return SessionProgress(self.request, key, session_type='file')
343
344    def execute_instance(self, upgrade, user, **kwargs):
345        session = orm.object_session(upgrade)
346        self.handler.mark_executing(upgrade)
347        session.commit()
348        self.handler.do_execute(upgrade, user, **kwargs)
349
350    def execute_progress(self):
351        upgrade = self.get_instance()
352        key = '{}.execute'.format(self.get_grid_key())
353        session = get_progress_session(self.request, key)
354        if session.get('complete'):
355            msg = session.get('success_msg')
356            if msg:
357                self.request.session.flash(msg)
358        elif session.get('error'):
359            self.request.session.flash(session.get('error_msg', "An unspecified error occurred."), 'error')
360        data = dict(session)
361
362        path = self.rattail_config.upgrade_filepath(upgrade.uuid, filename='stdout.log')
363        offset = session.get('stdout.offset', 0)
364        if os.path.exists(path):
365            size = os.path.getsize(path) - offset
366            if size > 0:
367                with open(path, 'rb') as f:
368                    f.seek(offset)
369                    chunk = f.read(size)
370                    data['stdout'] = chunk.decode('utf8').replace('\n', '<br />')
371                session['stdout.offset'] = offset + size
372                session.save()
373
374        return data
375
376    def delete_instance(self, upgrade):
377        self.handler.delete_files(upgrade)
378        super(UpgradeView, self).delete_instance(upgrade)
379
380    @classmethod
381    def defaults(cls, config):
382        route_prefix = cls.get_route_prefix()
383        url_prefix = cls.get_url_prefix()
384        permission_prefix = cls.get_permission_prefix()
385        model_key = cls.get_model_key()
386
387        # execution progress
388        config.add_route('{}.execute_progress'.format(route_prefix), '{}/{{{}}}/execute/progress'.format(url_prefix, model_key))
389        config.add_view(cls, attr='execute_progress', route_name='{}.execute_progress'.format(route_prefix),
390                        permission='{}.execute'.format(permission_prefix), renderer='json')
391
392        cls._defaults(config)
393
394
395def includeme(config):
396    UpgradeView.defaults(config)
Note: See TracBrowser for help on using the repository browser.