diff --git a/CHANGELOG.md b/CHANGELOG.md index 34f59cb..c7b0850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,32 +5,6 @@ All notable changes to wuttaweb will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## v0.13.0 (2024-08-26) - -### Feat - -- use native wuttjamaican app to send feedback email -- add basic user feedback email mechanism -- add "progress" page for executing upgrades -- add basic support for execute upgrades, download stdout/stderr -- add basic progress page/indicator support -- add basic "delete results" grid tool -- add initial views for upgrades -- allow app db to be rattail-native instead of wutta-native -- add per-row css class support for grids -- improve grid filter API a bit, support string/bool filters - -### Fix - -- tweak max image size for full logo on home, login pages -- improve handling of boolean form fields -- misc. improvements for display of grids, form errors -- use autocomplete for grid filter verb choices -- small cleanup for grid filters template -- add once-button action for grid Reset View -- set sort defaults for users, roles -- add override hook for base form template - ## v0.12.1 (2024-08-22) ### Fix diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst index 7299034..9749cae 100644 --- a/docs/api/wuttaweb/index.rst +++ b/docs/api/wuttaweb/index.rst @@ -20,7 +20,6 @@ handler helpers menus - progress static subscribers util @@ -31,8 +30,6 @@ views.essential views.master views.people - views.progress views.roles views.settings - views.upgrades views.users diff --git a/docs/api/wuttaweb/progress.rst b/docs/api/wuttaweb/progress.rst deleted file mode 100644 index 498d641..0000000 --- a/docs/api/wuttaweb/progress.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttaweb.progress`` -===================== - -.. automodule:: wuttaweb.progress - :members: diff --git a/docs/api/wuttaweb/views.people.rst b/docs/api/wuttaweb/views.people.rst index 2dc919b..89c6883 100644 --- a/docs/api/wuttaweb/views.people.rst +++ b/docs/api/wuttaweb/views.people.rst @@ -1,6 +1,6 @@ ``wuttaweb.views.people`` -========================= +=========================== .. automodule:: wuttaweb.views.people :members: diff --git a/docs/api/wuttaweb/views.progress.rst b/docs/api/wuttaweb/views.progress.rst deleted file mode 100644 index 34e2661..0000000 --- a/docs/api/wuttaweb/views.progress.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttaweb.views.progress`` -=========================== - -.. automodule:: wuttaweb.views.progress - :members: diff --git a/docs/api/wuttaweb/views.upgrades.rst b/docs/api/wuttaweb/views.upgrades.rst deleted file mode 100644 index 2909003..0000000 --- a/docs/api/wuttaweb/views.upgrades.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttaweb.views.upgrades`` -=========================== - -.. automodule:: wuttaweb.views.upgrades - :members: diff --git a/docs/conf.py b/docs/conf.py index 0f73d82..3d568ef 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,6 @@ intersphinx_mapping = { 'deform': ('https://docs.pylonsproject.org/projects/deform/en/latest/', None), 'pyramid': ('https://docs.pylonsproject.org/projects/pyramid/en/latest/', None), 'python': ('https://docs.python.org/3/', None), - 'rattail-manual': ('https://rattailproject.org/docs/rattail-manual/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), 'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None), } diff --git a/pyproject.toml b/pyproject.toml index fac1e9b..41fda6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.13.0" +version = "0.12.1" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -31,7 +31,6 @@ classifiers = [ requires-python = ">= 3.8" dependencies = [ "ColanderAlchemy", - "humanize", "paginate", "paginate_sqlalchemy", "pyramid>=2", @@ -42,7 +41,7 @@ dependencies = [ "pyramid_tm", "waitress", "WebHelpers2", - "WuttJamaican[db,email]>=0.13.0", + "WuttJamaican[db]>=0.12.1", "zope.sqlalchemy>=1.5", ] diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py index c263b60..88318b4 100644 --- a/src/wuttaweb/app.py +++ b/src/wuttaweb/app.py @@ -37,10 +37,9 @@ from wuttaweb.auth import WuttaSecurityPolicy class WebAppProvider(AppProvider): """ - The :term:`app provider` for WuttaWeb. This adds some methods to - the :term:`app handler`, which are specific to web apps. + The :term:`app provider` for WuttaWeb. This adds some methods + specific to web apps. """ - email_templates = 'wuttaweb:email/templates' def get_web_handler(self, **kwargs): """ diff --git a/src/wuttaweb/email/templates/feedback.html.mako b/src/wuttaweb/email/templates/feedback.html.mako deleted file mode 100644 index c483f1a..0000000 --- a/src/wuttaweb/email/templates/feedback.html.mako +++ /dev/null @@ -1,40 +0,0 @@ -## -*- coding: utf-8 -*- - - - - - -

User feedback from website

- - -

- % if user: - ${user} - % else: - ${user_name} - % endif -

- - -

${referrer}

- - -

${client_ip}

- - -

${message}

- - - diff --git a/src/wuttaweb/email/templates/feedback.txt.mako b/src/wuttaweb/email/templates/feedback.txt.mako deleted file mode 100644 index a73a55e..0000000 --- a/src/wuttaweb/email/templates/feedback.txt.mako +++ /dev/null @@ -1,23 +0,0 @@ -## -*- coding: utf-8; -*- - -# User feedback from website - -**User Name** - -% if user: -${user} -% else: -${user_name} -% endif - -**Referring URL** - -${referrer} - -**Client IP** - -${client_ip} - -**Message** - -${message} diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index 74839a7..4402fde 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -92,53 +92,6 @@ class ObjectNode(colander.SchemaNode): raise NotImplementedError(f"you must define {class_name}.objectify()") -class WuttaEnum(colander.Enum): - """ - Custom schema type for enum fields. - - This is a subclass of :class:`colander.Enum`, but adds a - default widget (``SelectWidget``) with enum choices. - - :param request: Current :term:`request` object. - """ - - def __init__(self, request, *args, **kwargs): - super().__init__(*args, **kwargs) - self.request = request - self.config = self.request.wutta_config - self.app = self.config.get_app() - - def widget_maker(self, **kwargs): - """ """ - - if 'values' not in kwargs: - kwargs['values'] = [(getattr(e, self.attr), getattr(e, self.attr)) - for e in self.enum_cls] - - return widgets.SelectWidget(**kwargs) - - -class WuttaSet(colander.Set): - """ - Custom schema type for :class:`python:set` fields. - - This is a subclass of :class:`colander.Set`, but adds - Wutta-related params to the constructor. - - :param request: Current :term:`request` object. - - :param session: Optional :term:`db session` to use instead of - :class:`wuttaweb.db.Session`. - """ - - def __init__(self, request, session=None): - super().__init__() - self.request = request - self.config = self.request.wutta_config - self.app = self.config.get_app() - self.session = session or Session() - - class ObjectRef(colander.SchemaType): """ Custom schema type for a model class reference field. @@ -246,7 +199,7 @@ class ObjectRef(colander.SchemaType): # fetch object from DB model = self.app.model - obj = self.session.get(self.model_class, value) + obj = self.session.query(self.model_class).get(value) # raise error if not found if not obj: @@ -294,28 +247,14 @@ class ObjectRef(colander.SchemaType): kwargs['values'] = values if 'url' not in kwargs: - kwargs['url'] = self.get_object_url + kwargs['url'] = lambda person: self.request.route_url('people.view', uuid=person.uuid) return widgets.ObjectRefWidget(self.request, **kwargs) - def get_object_url(self, obj): - """ - Returns the "view" URL for the given object, if applicable. - - This is used when rendering the field readonly. If this - method returns a URL then the field text will be wrapped with - a hyperlink, otherwise it will be shown as-is. - - Default logic always returns ``None``; subclass should - override as needed. - """ - class PersonRef(ObjectRef): """ - Custom schema type for a - :class:`~wuttjamaican:wuttjamaican.db.model.base.Person` reference - field. + Custom schema type for a ``Person`` reference field. This is a subclass of :class:`ObjectRef`. """ @@ -330,33 +269,26 @@ class PersonRef(ObjectRef): """ """ return query.order_by(self.model_class.full_name) - def get_object_url(self, person): - """ """ - return self.request.route_url('people.view', uuid=person.uuid) - -class UserRef(ObjectRef): +class WuttaSet(colander.Set): """ - Custom schema type for a - :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` reference - field. + Custom schema type for :class:`python:set` fields. - This is a subclass of :class:`ObjectRef`. + This is a subclass of :class:`colander.Set`, but adds + Wutta-related params to the constructor. + + :param request: Current :term:`request` object. + + :param session: Optional :term:`db session` to use instead of + :class:`wuttaweb.db.Session`. """ - @property - def model_class(self): - """ """ - model = self.app.model - return model.User - - def sort_query(self, query): - """ """ - return query.order_by(self.model_class.username) - - def get_object_url(self, user): - """ """ - return self.request.route_url('users.view', uuid=user.uuid) + def __init__(self, request, session=None): + super().__init__() + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + self.session = session or Session() class RoleRefs(WuttaSet): @@ -456,35 +388,3 @@ class Permissions(WuttaSet): kwargs['values'] = values return widgets.PermissionsWidget(self.request, **kwargs) - - -class FileDownload(colander.String): - """ - Custom schema type for a file download field. - - This field is only meant for readonly use, it does not handle file - uploads. - - It expects the incoming ``appstruct`` to be the path to a file on - disk (or null). - - Uses the :class:`~wuttaweb.forms.widgets.FileDownloadWidget` by - default. - - :param request: Current :term:`request` object. - - :param url: Optional URL for hyperlink. If not specified, file - name/size is shown with no hyperlink. - """ - - def __init__(self, request, *args, **kwargs): - self.url = kwargs.pop('url', None) - super().__init__(*args, **kwargs) - self.request = request - self.config = self.request.wutta_config - self.app = self.config.get_app() - - def widget_maker(self, **kwargs): - """ """ - kwargs.setdefault('url', self.url) - return widgets.FileDownloadWidget(self.request, **kwargs) diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py index 4db861a..837b6f1 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -39,10 +39,7 @@ in the namespace: * :class:`deform:deform.widget.MoneyInputWidget` """ -import os - import colander -import humanize from deform.widget import (Widget, TextInputWidget, TextAreaWidget, PasswordWidget, CheckedPasswordWidget, CheckboxWidget, SelectWidget, CheckboxChoiceWidget, @@ -150,63 +147,6 @@ class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget): self.session = session or Session() -class FileDownloadWidget(Widget): - """ - Widget for use with :class:`~wuttaweb.forms.schema.FileDownload` - fields. - - This only supports readonly, and shows a hyperlink to download the - file. Link text is the filename plus file size. - - This is a subclass of :class:`deform:deform.widget.Widget` and - uses these Deform templates: - - * ``readonly/filedownload`` - - :param request: Current :term:`request` object. - - :param url: Optional URL for hyperlink. If not specified, file - name/size is shown with no hyperlink. - """ - readonly_template = 'readonly/filedownload' - - def __init__(self, request, *args, **kwargs): - self.url = kwargs.pop('url', None) - super().__init__(*args, **kwargs) - self.request = request - self.config = self.request.wutta_config - self.app = self.config.get_app() - - def serialize(self, field, cstruct, **kw): - """ """ - # nb. readonly is the only way this rolls - kw['readonly'] = True - template = self.readonly_template - - path = cstruct or None - if path: - kw.setdefault('filename', os.path.basename(path)) - kw.setdefault('filesize', self.readable_size(path)) - if self.url: - kw.setdefault('url', self.url) - - else: - kw.setdefault('filename', None) - kw.setdefault('filesize', None) - - kw.setdefault('url', None) - values = self.get_template_values(field, cstruct, kw) - return field.renderer(template, **values) - - def readable_size(self, path): - """ """ - try: - size = os.path.getsize(path) - except os.error: - size = 0 - return humanize.naturalsize(size) - - class RoleRefsWidget(WuttaCheckboxChoiceWidget): """ Widget for use with User @@ -244,7 +184,7 @@ class RoleRefsWidget(WuttaCheckboxChoiceWidget): roles = [] if cstruct: for uuid in cstruct: - role = self.session.get(model.Role, uuid) + role = self.session.query(model.Role).get(uuid) if role: roles.append(role) kw['roles'] = roles @@ -288,10 +228,6 @@ class UserRefsWidget(WuttaCheckboxChoiceWidget): users.append(dict([(key, getattr(user, key)) for key in columns + ['uuid']])) - # do not render if no data - if not users: - return HTML.tag('span') - # grid grid = Grid(self.request, key='roles.view.users', columns=columns, data=users) diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 4ff990e..0f2c812 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -28,7 +28,7 @@ import functools import json import logging import warnings -from collections import namedtuple, OrderedDict +from collections import namedtuple import sqlalchemy as sa from sqlalchemy import orm @@ -339,16 +339,6 @@ class Grid: sorting. See :meth:`set_joiner()` for more info. - - .. attribute:: tools - - Dict of "tool" elements for the grid. Tools are usually buttons - (e.g. "Delete Results"), shown on top right of the grid. - - The keys for this dict are somewhat arbitrary, defined by the - caller. Values should be HTML literal elements. - - See also :meth:`add_tool()` and :meth:`set_tools()`. """ def __init__( @@ -379,7 +369,6 @@ class Grid: filters=None, filter_defaults=None, joiners=None, - tools=None, ): self.request = request self.vue_tagname = vue_tagname @@ -397,7 +386,6 @@ class Grid: self.app = self.config.get_app() self.set_columns(columns or self.get_columns()) - self.set_tools(tools) # sorting self.sortable = sortable @@ -670,33 +658,6 @@ class Grid: """ self.actions.append(GridAction(self.request, key, **kwargs)) - def set_tools(self, tools): - """ - Set the :attr:`tools` attribute using the given tools collection. - - This will normalize the list/dict to desired internal format. - """ - if tools and isinstance(tools, list): - if not any([isinstance(t, (tuple, list)) for t in tools]): - tools = [(self.app.make_uuid(), t) for t in tools] - self.tools = OrderedDict(tools or []) - - def add_tool(self, html, key=None): - """ - Add a new HTML snippet to the :attr:`tools` dict. - - :param html: HTML literal for the tool element. - - :param key: Optional key to use when adding to the - :attr:`tools` dict. If not specified, a random string is - generated. - - See also :meth:`set_tools()`. - """ - if not key: - key = self.app.make_uuid() - self.tools[key] = html - ############################## # joining methods ############################## @@ -1117,7 +1078,6 @@ class Grid: :returns: A :class:`~wuttaweb.grids.filters.GridFilter` instance. """ - key = kwargs.pop('key', None) # model_property is required model_property = None @@ -1142,7 +1102,7 @@ class Grid: # make filter kwargs['model_property'] = model_property - return factory(self.request, key or model_property.key, **kwargs) + return factory(self.request, model_property.key, **kwargs) def set_filter(self, key, filterinfo=None, **kwargs): """ @@ -1172,7 +1132,6 @@ class Grid: # filtr = filterinfo raise NotImplementedError else: - kwargs['key'] = key kwargs.setdefault('label', self.get_label(key)) filtr = self.make_filter(filterinfo or key, **kwargs) diff --git a/src/wuttaweb/menus.py b/src/wuttaweb/menus.py index c1c47dc..84d5534 100644 --- a/src/wuttaweb/menus.py +++ b/src/wuttaweb/menus.py @@ -168,11 +168,6 @@ class MenuHandler(GenericHandler): 'route': 'settings', 'perm': 'settings.list', }, - { - 'title': "Upgrades", - 'route': 'upgrades', - 'perm': 'upgrades.list', - }, ], } diff --git a/src/wuttaweb/progress.py b/src/wuttaweb/progress.py deleted file mode 100644 index 759c2da..0000000 --- a/src/wuttaweb/progress.py +++ /dev/null @@ -1,165 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# wuttaweb -- Web App for Wutta Framework -# Copyright © 2024 Lance Edgar -# -# This file is part of Wutta Framework. -# -# Wutta Framework is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) any -# later version. -# -# Wutta Framework is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# Wutta Framework. If not, see . -# -################################################################################ -""" -Progress Indicators -""" - -from wuttjamaican.progress import ProgressBase - -from beaker.session import Session as BeakerSession - - -def get_basic_session(request, **kwargs): - """ - Create/get a "basic" Beaker session object. - """ - kwargs['use_cookies'] = False - return BeakerSession(request, **kwargs) - - -def get_progress_session(request, key, **kwargs): - """ - Create/get a Beaker session object, to be used for progress. - """ - kwargs['id'] = f'{request.session.id}.progress.{key}' - return get_basic_session(request, **kwargs) - - -class SessionProgress(ProgressBase): - """ - Progress indicator which uses Beaker session storage to track - current status. - - This is a subclass of - :class:`wuttjamaican:wuttjamaican.progress.ProgressBase`. - - A view callable can create one of these, and then pass it into - :meth:`~wuttjamaican.app.AppHandler.progress_loop()` or similar. - - As the loop updates progress along the way, this indicator will - update the Beaker session to match. - - Separately then, the client side can send requests for the - :func:`~wuttaweb.views.progress.progress()` view, to fetch current - status out of the Beaker session. - - :param request: Current :term:`request` object. - - :param key: Unique key for this progress indicator. Used to - distinguish progress indicators in the Beaker session. - - Note that in addition to - :meth:`~wuttjamaican:wuttjamaican.progress.ProgressBase.update()` - and - :meth:`~wuttjamaican:wuttjamaican.progress.ProgressBase.finish()` - this progres class has some extra attributes and methods: - - .. attribute:: success_msg - - Optional message to display to the user (via session flash) - when the operation completes successfully. - - .. attribute:: success_url - - URL to which user should be redirected, once the operation - completes. - - .. attribute:: error_url - - URL to which user should be redirected, if the operation - encounters an error. If not specified, will fall back to - :attr:`success_url`. - """ - - def __init__(self, request, key, success_msg=None, success_url=None, error_url=None): - self.key = key - self.success_msg = success_msg - self.success_url = success_url - self.error_url = error_url or self.success_url - self.session = get_progress_session(request, key) - self.clear() - - def __call__(self, message, maximum): - self.clear() - self.session['message'] = message - self.session['maximum'] = maximum - self.session['maximum_display'] = f'{maximum:,d}' - self.session['value'] = 0 - self.session.save() - return self - - def clear(self): - """ """ - self.session.clear() - self.session['complete'] = False - self.session['error'] = False - self.session.save() - - def update(self, value): - """ """ - self.session.load() - self.session['value'] = value - self.session.save() - - def handle_error(self, error, error_url=None): - """ - This should be called by the view code, within a try/catch - block upon error. - - The session storage will be updated to reflect details of the - error. Next time client requests the progress status it will - learn of the error and redirect the user. - - :param error: :class:`python:Exception` instance. - - :param error_url: Optional redirect URL; if not specified - :attr:`error_url` is used. - """ - self.session.load() - self.session['error'] = True - self.session['error_msg'] = str(error) - self.session['error_url'] = error_url or self.error_url - self.session.save() - - def handle_success(self, success_msg=None, success_url=None): - """ - This should be called by the view code, when the long-running - operation completes. - - The session storage will be updated to reflect the completed - status. Next time client requests the progress status it will - discover it has completed, and redirect the user. - - :param success_msg: Optional message to display to the user - (via session flash) when the operation completes - successfully. If not specified :attr:`success_msg` (or - nothing) is used - - :param success_url: Optional redirect URL; if not specified - :attr:`success_url` is used. - """ - self.session.load() - self.session['complete'] = True - self.session['success_msg'] = success_msg or self.success_msg - self.session['success_url'] = success_url or self.success_url - self.session.save() diff --git a/src/wuttaweb/templates/appinfo/configure.mako b/src/wuttaweb/templates/appinfo/configure.mako index 03f1551..d2de2cf 100644 --- a/src/wuttaweb/templates/appinfo/configure.mako +++ b/src/wuttaweb/templates/appinfo/configure.mako @@ -73,54 +73,6 @@ -

Email

-
- - - - Enable email sending - - - -
- - - - - - - - - - - - - - - - - - - - - -
- -
-

Web Libraries

@@ -267,19 +219,6 @@ this.editWebLibraryShowDialog = false } - ThisPage.methods.validateEmailSettings = function() { - if (this.simpleSettings['${config.appname}.mail.send_emails']) { - if (!this.simpleSettings['${config.appname}.email.default.sender']) { - return "Default Sender is required to send email." - } - if (!this.simpleSettings['${config.appname}.email.default.to']) { - return "Default Recipient(s) are required to send email." - } - } - } - - ThisPageData.validators.push(ThisPage.methods.validateEmailSettings) - diff --git a/src/wuttaweb/templates/appinfo/index.mako b/src/wuttaweb/templates/appinfo/index.mako index 383157f..279a41e 100644 --- a/src/wuttaweb/templates/appinfo/index.mako +++ b/src/wuttaweb/templates/appinfo/index.mako @@ -20,10 +20,7 @@ ${app.get_node_title()} - ${"Yes" if config.production() else "No"} - - - ${"Yes" if app.get_email_handler().sending_is_enabled() else "No"} + ${config.production()}
diff --git a/src/wuttaweb/templates/auth/login.mako b/src/wuttaweb/templates/auth/login.mako index 0235885..7c1d846 100644 --- a/src/wuttaweb/templates/auth/login.mako +++ b/src/wuttaweb/templates/auth/login.mako @@ -21,8 +21,8 @@ justify-content: center; } .wutta-logo img { - max-height: 480px; - max-width: 640px; + max-height: 350px; + max-width: 800px; } diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index dbad2e2..a85bc2d 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -3,7 +3,13 @@ <%namespace file="/wutta-components.mako" import="make_wutta_components" /> - ${self.html_head()} + + + ${base_meta.global_title()} » ${capture(self.title)|n} + ${base_meta.favicon()} + ${self.header_core()} + ${self.head_tags()} +
@@ -24,20 +30,7 @@ -<%def name="html_head()"> - - - ${self.head_title()} - ${base_meta.favicon()} - ${self.header_core()} - ${self.head_tags()} - - - -## nb. this is the full within html <head> -<%def name="head_title()">${base_meta.global_title()} » ${self.title()}</%def> - -## nb. this becomes part of head_title() above +## nb. this becomes part of the page <title> tag within <head> ## it also is used as default value for content_title() below <%def name="title()"></%def> @@ -46,9 +39,9 @@ <%def name="content_title()">${self.title()}</%def> <%def name="header_core()"> - ${self.base_javascript()} + ${self.core_javascript()} ${self.extra_javascript()} - ${self.base_styles()} + ${self.core_styles()} ${self.extra_styles()} </%def> @@ -56,10 +49,6 @@ ${self.vuejs()} ${self.buefy()} ${self.fontawesome()} -</%def> - -<%def name="base_javascript()"> - ${self.core_javascript()} ${self.hamburger_menu_js()} </%def> @@ -110,6 +99,7 @@ <%def name="core_styles()"> ${self.buefy_styles()} + ${self.base_styles()} </%def> <%def name="buefy_styles()"> @@ -117,7 +107,6 @@ </%def> <%def name="base_styles()"> - ${self.core_styles()} <style> ############################## @@ -205,7 +194,16 @@ <%def name="head_tags()"></%def> -<%def name="whole_page_content()"> +<%def name="render_vue_template_whole_page()"> + <script type="text/x-template" id="whole-page-template"> + + ## nb. the whole-page contains 3 elements: + ## 1) header-wrapper + ## 2) content-wrapper + ## 3) footer + <div id="whole-page" + style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> + ## nb. the header-wrapper contains 2 elements: ## 1) header proper (menu + index title area) ## 2) page/content title area @@ -329,18 +327,7 @@ ${base_meta.footer()} </div> </footer> -</%def> -<%def name="render_vue_template_whole_page()"> - <script type="text/x-template" id="whole-page-template"> - - ## nb. the whole-page normally contains 3 elements: - ## 1) header-wrapper - ## 2) content-wrapper - ## 3) footer - <div id="whole-page" - style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;"> - ${self.whole_page_content()} </div> </script> </%def> @@ -420,153 +407,7 @@ <%def name="render_theme_picker()"></%def> -<%def name="render_feedback_button()"> - % if request.has_perm('common.feedback'): - <wutta-feedback-form action="${url('feedback')}" /> - % endif -</%def> - -<%def name="render_vue_template_feedback()"> - <script type="text/x-template" id="wutta-feedback-template"> - <div> - - <b-button type="is-primary" - @click="showFeedback()" - icon-pack="fas" - icon-left="comment"> - Feedback - </b-button> - - <b-modal has-modal-card - :active.sync="showDialog"> - <div class="modal-card"> - - <header class="modal-card-head"> - <p class="modal-card-title">User Feedback</p> - </header> - - <section class="modal-card-body"> - <p class="block"> - Feedback regarding this website may be submitted below. - </p> - - <b-field label="User Name" - :type="userName && userName.trim() ? null : 'is-danger'"> - <b-input v-model.trim="userName" - % if request.user: - disabled - % endif - /> - </b-field> - - <b-field label="Referring URL"> - <b-input v-model="referrer" - disabled="true" /> - </b-field> - - <b-field label="Message" - :type="message && message.trim() ? null : 'is-danger'"> - <b-input type="textarea" - v-model.trim="message" - ref="textarea" /> - </b-field> - </section> - - <footer class="modal-card-foot"> - <b-button @click="showDialog = false"> - Cancel - </b-button> - <b-button type="is-primary" - icon-pack="fas" - icon-left="paper-plane" - @click="sendFeedback()" - :disabled="submitDisabled"> - {{ sendingFeedback ? "Working, please wait..." : "Send Message" }} - </b-button> - </footer> - </div> - </b-modal> - - </div> - </script> -</%def> - -<%def name="render_vue_script_feedback()"> - <script> - - const WuttaFeedbackForm = { - template: '#wutta-feedback-template', - mixins: [WuttaRequestMixin], - props: { - action: String, - }, - computed: { - - submitDisabled() { - if (this.sendingFeedback) { - return true - } - if (!this.userName || !this.userName.trim()) { - return true - } - if (!this.message || !this.message.trim()) { - return true - } - return false - }, - }, - methods: { - - showFeedback() { - // nb. update referrer to include anchor hash if any - this.referrer = location.href - this.showDialog = true - this.$nextTick(function() { - this.$refs.textarea.focus() - }) - }, - - sendFeedback() { - this.sendingFeedback = true - - const params = { - referrer: this.referrer, - user_uuid: this.userUUID, - user_name: this.userName, - message: this.message.trim(), - } - - this.wuttaPOST(this.action, params, response => { - - this.$buefy.toast.open({ - message: "Message sent! Thank you for your feedback.", - type: 'is-info', - duration: 4000, // 4 seconds - }) - - this.showDialog = false - // clear out message, in case they need to send another - this.message = "" - this.sendingFeedback = false - - }, response => { // failure - this.sendingFeedback = false - }) - }, - } - } - - const WuttaFeedbackFormData = { - referrer: null, - userUUID: ${json.dumps(request.user.uuid if request.user else None)|n}, - userName: ${json.dumps(str(request.user) if request.user else None)|n}, - showDialog: false, - sendingFeedback: false, - message: '', - } - - </script> -</%def> +<%def name="render_feedback_button()"></%def> <%def name="render_vue_script_whole_page()"> <script> @@ -577,7 +418,7 @@ mounted() { for (let hook of this.mountedHooks) { - hook.call(this) + hook(this) } }, @@ -724,33 +565,18 @@ ############################## <%def name="render_vue_templates()"> - - ## nb. must make wutta components first; they are stable so - ## intermediate pages do not need to modify them. and some pages - ## may need the request mixin to be defined. - ${make_wutta_components()} - ${self.render_vue_template_whole_page()} ${self.render_vue_script_whole_page()} - % if request.has_perm('common.feedback'): - ${self.render_vue_template_feedback()} - ${self.render_vue_script_feedback()} - % endif </%def> <%def name="modify_vue_vars()"></%def> <%def name="make_vue_components()"> + ${make_wutta_components()} <script> WholePage.data = function() { return WholePageData } Vue.component('whole-page', WholePage) </script> - % if request.has_perm('common.feedback'): - <script> - WuttaFeedbackForm.data = function() { return WuttaFeedbackFormData } - Vue.component('wutta-feedback-form', WuttaFeedbackForm) - </script> - % endif </%def> <%def name="make_vue_app()"> diff --git a/src/wuttaweb/templates/configure.mako b/src/wuttaweb/templates/configure.mako index 20878a3..f0363e0 100644 --- a/src/wuttaweb/templates/configure.mako +++ b/src/wuttaweb/templates/configure.mako @@ -167,11 +167,7 @@ for (let validator of this.validators) { let msg = validator.call(this) if (msg) { - this.$buefy.toast.open({ - message: msg, - type: 'is-warning', - duration: 4000, // 4 seconds - }) + alert(msg) return } } diff --git a/src/wuttaweb/templates/deform/readonly/filedownload.pt b/src/wuttaweb/templates/deform/readonly/filedownload.pt deleted file mode 100644 index 31a789b..0000000 --- a/src/wuttaweb/templates/deform/readonly/filedownload.pt +++ /dev/null @@ -1,14 +0,0 @@ -<tal:omit> - <a tal:condition="url" href="${url}"> - ${filename} - <tal:omit tal:condition="filesize"> - (${filesize}) - </tal:omit> - </a> - <span tal:condition="not url"> - ${filename} - <tal:omit tal:condition="filesize"> - (${filesize}) - </tal:omit> - </span> -</tal:omit> diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako index e3a7a23..1ed408e 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -90,19 +90,6 @@ </form> % endif - <div style="display: flex; flex-direction: column; justify-content: space-between;"> - - ## nb. this is needed to force tools to bottom - ## TODO: should we put a context menu here? - <div></div> - - <div class="wutta-grid-tools-wrapper"> - % for html in grid.tools.values(): - ${html} - % endfor - </div> - </div> - </div> <${b}-table :data="data" @@ -303,14 +290,6 @@ template: '#${grid.vue_tagname}-template', computed: { - recordCount() { - % if grid.paginated: - return this.pagerStats.item_count - % else: - return this.data.length - % endif - }, - directLink() { const params = new URLSearchParams(this.getAllParams()) return `${request.path_url}?${'$'}{params}` diff --git a/src/wuttaweb/templates/home.mako b/src/wuttaweb/templates/home.mako index 46d80cd..e387227 100644 --- a/src/wuttaweb/templates/home.mako +++ b/src/wuttaweb/templates/home.mako @@ -21,8 +21,8 @@ justify-content: center; } .wutta-logo img { - max-height: 480px; - max-width: 640px; + max-height: 350px; + max-width: 800px; } </style> </%def> diff --git a/src/wuttaweb/templates/master/index.mako b/src/wuttaweb/templates/master/index.mako index bf32c6f..a16aced 100644 --- a/src/wuttaweb/templates/master/index.mako +++ b/src/wuttaweb/templates/master/index.mako @@ -23,41 +23,6 @@ % endif </%def> -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - % if master.deletable_bulk and master.has_perm('delete_bulk'): - <script> - - ${grid.vue_component}Data.deleteResultsSubmitting = false - - ${grid.vue_component}.computed.deleteResultsDisabled = function() { - if (this.deleteResultsSubmitting) { - return true - } - if (!this.recordCount) { - return true - } - return false - } - - ${grid.vue_component}.methods.deleteResultsSubmit = function() { - - ## TODO: should give a better dialog here - const msg = "You are about to delete " - + this.recordCount.toLocaleString('en') - + " records.\n\nAre you sure?" - if (!confirm(msg)) { - return - } - - this.deleteResultsSubmitting = true - this.$refs.deleteResultsForm.submit() - } - - </script> - % endif -</%def> - <%def name="make_vue_components()"> ${parent.make_vue_components()} % if grid is not Undefined: diff --git a/src/wuttaweb/templates/progress.mako b/src/wuttaweb/templates/progress.mako deleted file mode 100644 index ddf5a74..0000000 --- a/src/wuttaweb/templates/progress.mako +++ /dev/null @@ -1,127 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/base.mako" /> - -<%def name="head_title()">${initial_msg or "Working"}...</%def> - -<%def name="base_javascript()"> - ${self.core_javascript()} -</%def> - -<%def name="base_styles()"> - ${self.core_styles()} -</%def> - -<%def name="whole_page_content()"> - <section class="hero is-fullheight"> - <div class="hero-body"> - <div class="container"> - - <div style="display: flex;"> - <div style="flex-grow: 1;"></div> - <div> - - <p class="block"> - {{ progressMessage }} ... {{ totalDisplay }} - </p> - - <div class="level"> - - <div class="level-item"> - <b-progress size="is-large" - style="width: 400px;" - :max="progressMax" - :value="progressValue" - show-value - format="percent" - precision="0"> - </b-progress> - </div> - - </div> - - </div> - <div style="flex-grow: 1;"></div> - </div> - - ${self.after_progress()} - - </div> - </div> - </section> -</%def> - -<%def name="after_progress()"></%def> - -<%def name="modify_vue_vars()"> - <script> - - WholePageData.progressURL = '${url('progress', key=progress.key)}' - WholePageData.progressMessage = "${(initial_msg or "Working").replace('"', '\\"')} (please wait)" - WholePageData.progressMax = null - WholePageData.progressMaxDisplay = null - WholePageData.progressValue = null - WholePageData.stillInProgress = true - - WholePage.computed.totalDisplay = function() { - - if (!this.stillInProgress) { - return "done!" - } - - if (this.progressMaxDisplay) { - return `(${'$'}{this.progressMaxDisplay} total)` - } - } - - WholePageData.mountedHooks.push(function() { - - // fetch first progress data, one second from now - setTimeout(() => { - this.updateProgress() - }, 1000) - }) - - WholePage.methods.updateProgress = function() { - - this.$http.get(this.progressURL).then(response => { - - if (response.data.error) { - // errors stop the show; redirect - location.href = response.data.error_url - - } else { - - if (response.data.complete || response.data.maximum) { - this.progressMessage = response.data.message - this.progressMaxDisplay = response.data.maximum_display - - if (response.data.complete) { - this.progressValue = this.progressMax - this.stillInProgress = false - - location.href = response.data.success_url - - } else { - this.progressValue = response.data.value - this.progressMax = response.data.maximum - } - } - - // custom logic if applicable - this.updateProgressCustom(response) - - if (this.stillInProgress) { - - // fetch progress data again, in one second from now - setTimeout(() => { - this.updateProgress() - }, 1000) - } - } - }) - } - - WholePage.methods.updateProgressCustom = function(response) {} - - </script> -</%def> diff --git a/src/wuttaweb/templates/upgrade.mako b/src/wuttaweb/templates/upgrade.mako deleted file mode 100644 index 72a4424..0000000 --- a/src/wuttaweb/templates/upgrade.mako +++ /dev/null @@ -1,64 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/progress.mako" /> - -<%def name="extra_styles()"> - ${parent.extra_styles()} - <style> - - .upgrade-textout { - border: 1px solid Black; - line-height: 1.2; - margin-top: 1rem; - overflow: auto; - padding: 1rem; - } - - </style> -</%def> - -<%def name="after_progress()"> - <div ref="textout" - class="upgrade-textout is-family-monospace is-size-7"> - <span v-for="line in progressOutput" - :key="line.key" - v-html="line.text"> - </span> - - ## nb. we auto-scroll down to "see" this element - <div ref="seeme"></div> - </div> -</%def> - -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> - - WholePageData.progressURL = '${url('upgrades.execute_progress', uuid=instance.uuid)}' - WholePageData.progressOutput = [] - WholePageData.progressOutputCounter = 0 - - WholePageData.mountedHooks.push(function() { - - // grow the textout area to fill most of screen - const textout = this.$refs.textout - const height = window.innerHeight - textout.offsetTop - 100 - textout.style.height = height + 'px' - }) - - WholePage.methods.updateProgressCustom = function(response) { - if (response.data.stdout) { - - // add lines to textout area - this.progressOutput.push({ - key: ++this.progressOutputCounter, - text: response.data.stdout}) - - // scroll down to end of textout area - this.$nextTick(() => { - this.$refs.seeme.scrollIntoView({behavior: 'smooth'}) - }) - } - } - - </script> -</%def> diff --git a/src/wuttaweb/templates/upgrades/configure.mako b/src/wuttaweb/templates/upgrades/configure.mako deleted file mode 100644 index 2e4eae1..0000000 --- a/src/wuttaweb/templates/upgrades/configure.mako +++ /dev/null @@ -1,20 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/master/configure.mako" /> - -<%def name="form_content()"> - - <h3 class="is-size-3">Basics</h3> - <div class="block" style="padding-left: 2rem; width: 50%;"> - - <b-field label="Upgrade Script (for Execute)" - message="The command + args will be interpreted by the shell."> - <b-input name="${app.appname}.upgrades.command" - v-model="simpleSettings['${app.appname}.upgrades.command']" - @input="settingsNeedSaved = true" - ## ref="upgradeSystemCommand" - ## expanded - /> - </b-field> - - </div> -</%def> diff --git a/src/wuttaweb/templates/upgrades/view.mako b/src/wuttaweb/templates/upgrades/view.mako deleted file mode 100644 index 4794641..0000000 --- a/src/wuttaweb/templates/upgrades/view.mako +++ /dev/null @@ -1,37 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/master/view.mako" /> - -<%def name="page_content()"> - ${parent.page_content()} - % if instance.status == app.enum.UpgradeStatus.PENDING and master.has_perm('execute'): - <div class="buttons" - style="margin: 2rem 5rem;"> - - ${h.form(master.get_action_url('execute', instance), **{'@submit': 'executeFormSubmit'})} - ${h.csrf_token(request)} - <b-button type="is-primary" - native-type="submit" - icon-pack="fas" - icon-left="arrow-circle-right" - :disabled="executeFormSubmitting"> - {{ executeFormSubmitting ? "Working, please wait..." : "Execute this upgrade" }} - </b-button> - ${h.end_form()} - </div> - % endif -</%def> - -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - % if instance.status == app.enum.UpgradeStatus.PENDING and master.has_perm('execute'): - <script> - - ThisPageData.executeFormSubmitting = false - - ThisPage.methods.executeFormSubmit = function() { - this.executeFormSubmitting = true - } - - </script> - % endif -</%def> diff --git a/src/wuttaweb/templates/wutta-components.mako b/src/wuttaweb/templates/wutta-components.mako index b52992e..888944e 100644 --- a/src/wuttaweb/templates/wutta-components.mako +++ b/src/wuttaweb/templates/wutta-components.mako @@ -1,87 +1,10 @@ <%def name="make_wutta_components()"> - ${self.make_wutta_request_mixin()} ${self.make_wutta_button_component()} ${self.make_wutta_filter_component()} ${self.make_wutta_filter_value_component()} </%def> -<%def name="make_wutta_request_mixin()"> - <script> - - const WuttaRequestMixin = { - methods: { - - wuttaGET(url, params, success, failure) { - - this.$http.get(url, {params: params}).then(response => { - - if (response.data.error) { - this.$buefy.toast.open({ - message: `Request failed: ${'$'}{response.data.error}`, - type: 'is-danger', - duration: 4000, // 4 seconds - }) - if (failure) { - failure(response) - } - - } else { - success(response) - } - - }, response => { - this.$buefy.toast.open({ - message: "Request failed: (unknown server error)", - type: 'is-danger', - duration: 4000, // 4 seconds - }) - if (failure) { - failure(response) - } - }) - - }, - - wuttaPOST(action, params, success, failure) { - - const csrftoken = ${json.dumps(h.get_csrf_token(request))|n} - const headers = {'X-CSRF-TOKEN': csrftoken} - - this.$http.post(action, params, {headers: headers}).then(response => { - - if (response.data.error) { - this.$buefy.toast.open({ - message: "Submit failed: " + (response.data.error || - "(unknown error)"), - type: 'is-danger', - duration: 4000, // 4 seconds - }) - if (failure) { - failure(response) - } - - } else { - success(response) - } - - }, response => { - this.$buefy.toast.open({ - message: "Submit failed! (unknown server error)", - type: 'is-danger', - duration: 4000, // 4 seconds - }) - if (failure) { - failure(response) - } - }) - }, - }, - } - - </script> -</%def> - <%def name="make_wutta_button_component()"> <script type="text/x-template" id="wutta-button-template"> <b-button :type="type" diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py index 29065fa..0b230b4 100644 --- a/src/wuttaweb/util.py +++ b/src/wuttaweb/util.py @@ -83,32 +83,6 @@ class FieldList(list): field, newfield) self.append(newfield) - def set_sequence(self, fields): - """ - Sort the list such that it matches the same sequence as the - given fields list. - - This does not add or remove any elements, it just - (potentially) rearranges the internal list elements. - Therefore you do not need to explicitly declare *all* fields; - just the ones you care about. - - The resulting field list will have the requested fields in - order, at the *beginning* of the list. Any unrequested fields - will remain in the same order as they were previously, but - will be placed *after* the requested fields. - - :param fields: List of fields in the desired order. - """ - unimportant = len(self) + 1 - - def getkey(field): - if field in fields: - return fields.index(field) - return unimportant - - self.sort(key=getkey) - def get_form_data(request): """ diff --git a/src/wuttaweb/views/base.py b/src/wuttaweb/views/base.py index 5121f3c..bc1e76c 100644 --- a/src/wuttaweb/views/base.py +++ b/src/wuttaweb/views/base.py @@ -24,11 +24,8 @@ Base Logic for Views """ -import os - from pyramid import httpexceptions from pyramid.renderers import render_to_response -from pyramid.response import FileResponse from wuttaweb import forms, grids @@ -122,46 +119,9 @@ class View: """ return httpexceptions.HTTPFound(location=url, **kwargs) - def file_response(self, path, attachment=True, filename=None): - """ - Returns a generic file response for the given path. - - :param path: Path to a file on local disk; must be accessible - by the web app. - - :param attachment: Whether the file should come down as an - "attachment" instead of main payload. - - The attachment behavior is the default here, and will cause - the user to be prompted for where to save the file. - - Set ``attachment=False`` in order to cause the browser to - render the file as if it were the page being navigated to. - - :param filename: Optional filename to use for attachment - behavior. This will be the "suggested filename" when user - is prompted to save the download. If not specified, the - filename is derived from ``path``. - - :returns: A :class:`~pyramid:pyramid.response.FileResponse` - object with file content. - """ - if not os.path.exists(path): - return self.notfound() - - response = FileResponse(path, request=self.request) - response.content_length = os.path.getsize(path) - - if attachment: - if not filename: - filename = os.path.basename(path) - response.content_disposition = f'attachment; filename="{filename}"' - - return response - def json_response(self, context): """ - Returns a JSON response with the given context data. + Convenience method to return a JSON response. :param context: Context data to be rendered as JSON. diff --git a/src/wuttaweb/views/common.py b/src/wuttaweb/views/common.py index 309ecc3..a13fc50 100644 --- a/src/wuttaweb/views/common.py +++ b/src/wuttaweb/views/common.py @@ -24,19 +24,13 @@ Common Views """ -import logging - import colander -from pyramid.renderers import render from wuttaweb.views import View from wuttaweb.forms import widgets from wuttaweb.db import Session -log = logging.getLogger(__name__) - - class CommonView(View): """ Common views shared by all apps. @@ -84,58 +78,6 @@ class CommonView(View): """ return {'index_title': self.app.get_title()} - def feedback(self): - """ """ - model = self.app.model - session = Session() - - # validate form - schema = self.feedback_make_schema() - form = self.make_form(schema=schema) - if not form.validate(): - # TODO: native Form class should better expose error(s) - dform = form.get_deform() - return {'error': str(dform.error)} - - # build email template context - context = dict(form.validated) - if context['user_uuid']: - context['user'] = session.get(model.User, context['user_uuid']) - context['user_url'] = self.request.route_url('users.view', uuid=context['user_uuid']) - context['client_ip'] = self.request.client_addr - - # send email - try: - self.feedback_send(context) - except Exception as error: - log.warning("failed to send feedback email", exc_info=True) - return {'error': str(error) or error.__class__.__name__} - - return {'ok': True} - - def feedback_make_schema(self): - """ """ - schema = colander.Schema() - - schema.add(colander.SchemaNode(colander.String(), - name='referrer')) - - schema.add(colander.SchemaNode(colander.String(), - name='user_uuid', - missing=None)) - - schema.add(colander.SchemaNode(colander.String(), - name='user_name')) - - schema.add(colander.SchemaNode(colander.String(), - name='message')) - - return schema - - def feedback_send(self, context): - """ """ - self.app.send_email('feedback', context) - def setup(self, session=None): """ View for first-time app setup, to create admin user. @@ -212,15 +154,6 @@ class CommonView(View): 'settings.view', 'settings.edit', 'settings.delete', - 'settings.delete_bulk', - 'upgrades.list', - 'upgrades.create', - 'upgrades.view', - 'upgrades.edit', - 'upgrades.delete', - 'upgrades.execute', - 'upgrades.download', - 'upgrades.configure', 'users.list', 'users.create', 'users.view', @@ -261,8 +194,6 @@ class CommonView(View): @classmethod def _defaults(cls, config): - config.add_wutta_permission_group('common', "(common)", overwrite=False) - # home page config.add_route('home', '/') config.add_view(cls, attr='home', @@ -279,16 +210,6 @@ class CommonView(View): append_slash=True, renderer='/notfound.mako') - # feedback - config.add_route('feedback', '/feedback', - request_method='POST') - config.add_view(cls, attr='feedback', - route_name='feedback', - permission='common.feedback', - renderer='json') - config.add_wutta_permission('common', 'common.feedback', - "Send user feedback about the app") - # setup config.add_route('setup', '/setup') config.add_view(cls, attr='setup', diff --git a/src/wuttaweb/views/essential.py b/src/wuttaweb/views/essential.py index 56c669b..1387a99 100644 --- a/src/wuttaweb/views/essential.py +++ b/src/wuttaweb/views/essential.py @@ -32,11 +32,9 @@ That will in turn include the following modules: * :mod:`wuttaweb.views.auth` * :mod:`wuttaweb.views.common` * :mod:`wuttaweb.views.settings` -* :mod:`wuttaweb.views.progress` * :mod:`wuttaweb.views.people` * :mod:`wuttaweb.views.roles` * :mod:`wuttaweb.views.users` -* :mod:`wuttaweb.views.upgrades` """ @@ -46,11 +44,9 @@ def defaults(config, **kwargs): config.include(mod('wuttaweb.views.auth')) config.include(mod('wuttaweb.views.common')) config.include(mod('wuttaweb.views.settings')) - config.include(mod('wuttaweb.views.progress')) config.include(mod('wuttaweb.views.people')) config.include(mod('wuttaweb.views.roles')) config.include(mod('wuttaweb.views.users')) - config.include(mod('wuttaweb.views.upgrades')) def includeme(config): diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index c5ff5d7..a834b1d 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -24,10 +24,6 @@ Base Logic for Master Views """ -import logging -import os -import threading - import sqlalchemy as sa from sqlalchemy import orm @@ -35,15 +31,11 @@ from pyramid.renderers import render_to_response from webhelpers2.html import HTML from wuttaweb.views import View -from wuttaweb.util import get_form_data, get_model_fields, render_csrf_token +from wuttaweb.util import get_form_data, get_model_fields from wuttaweb.db import Session -from wuttaweb.progress import SessionProgress from wuttjamaican.util import get_class_hierarchy -log = logging.getLogger(__name__) - - class MasterView(View): """ Base class for "master" views. @@ -76,7 +68,7 @@ class MasterView(View): Optional reference to a data model class. While not strictly required, most views will set this to a SQLAlchemy mapped class, - e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`. + e.g. :class:`wuttjamaican:wuttjamaican.db.model.auth.User`. Code should not access this directly but instead call :meth:`get_model_class()`. @@ -292,25 +284,6 @@ class MasterView(View): See also :meth:`is_deletable()`. - .. attribute:: deletable_bulk - - Boolean indicating whether the view model supports "bulk - deleting" - i.e. it should have a :meth:`delete_bulk()` view. - Default value is ``False``. - - See also :attr:`deletable_bulk_quick`. - - .. attribute:: deletable_bulk_quick - - Boolean indicating whether the view model supports "quick" bulk - deleting, i.e. the operation is reliably quick enough that it - should happen *synchronously* with no progress indicator. - - Default is ``False`` in which case a progress indicator is - shown while the bulk deletion is performed. - - Only relevant if :attr:`deletable_bulk` is true. - .. attribute:: form_fields List of fields for the model form. @@ -323,18 +296,6 @@ class MasterView(View): "autocomplete" - i.e. it should have an :meth:`autocomplete()` view. Default is ``False``. - .. attribute:: downloadable - - Boolean indicating whether the view model supports - "downloading" - i.e. it should have a :meth:`download()` view. - Default is ``False``. - - .. attribute:: executable - - Boolean indicating whether the view model supports "executing" - - i.e. it should have an :meth:`execute()` view. Default is - ``False``. - .. attribute:: configurable Boolean indicating whether the master view supports @@ -360,12 +321,7 @@ class MasterView(View): viewable = True editable = True deletable = True - deletable_bulk = False - deletable_bulk_quick = False has_autocomplete = False - downloadable = False - executable = False - execute_progress_template = None configurable = False # current action @@ -666,120 +622,6 @@ class MasterView(View): session = self.app.get_session(obj) session.delete(obj) - def delete_bulk(self): - """ - View to delete all records in the current :meth:`index()` grid - data set, i.e. those matching current query. - - This usually corresponds to a URL like - ``/widgets/delete-bulk``. - - By default, this view is included only if - :attr:`deletable_bulk` is true. - - This view requires POST method. When it is finished deleting, - user is redirected back to :meth:`index()` view. - - Subclass normally should not override this method, but rather - one of the related methods which are called (in)directly by - this one: - - * :meth:`delete_bulk_action()` - """ - - # get current data set from grid - # nb. this must *not* be paginated, we need it all - grid = self.make_model_grid(paginated=False) - data = grid.get_visible_data() - - if self.deletable_bulk_quick: - - # delete it all and go back to listing - self.delete_bulk_action(data) - return self.redirect(self.get_index_url()) - - else: - - # start thread for delete; show progress page - route_prefix = self.get_route_prefix() - key = f'{route_prefix}.delete_bulk' - progress = self.make_progress(key, success_url=self.get_index_url()) - thread = threading.Thread(target=self.delete_bulk_thread, - args=(data,), kwargs={'progress': progress}) - thread.start() - return self.render_progress(progress) - - def delete_bulk_thread(self, query, success_url=None, progress=None): - """ """ - model_title_plural = self.get_model_title_plural() - - # nb. use new session, separate from web transaction - session = self.app.make_session() - records = query.with_session(session).all() - - try: - self.delete_bulk_action(records, progress=progress) - - except Exception as error: - session.rollback() - log.warning("failed to delete %s results for %s", - len(records), model_title_plural, - exc_info=True) - if progress: - progress.handle_error(error) - - else: - session.commit() - if progress: - progress.handle_success() - - finally: - session.close() - - def delete_bulk_action(self, data, progress=None): - """ - This method performs the actual bulk deletion, for the given - data set. This is called via :meth:`delete_bulk()`. - - Default logic will call :meth:`is_deletable()` for every data - record, and if that returns true then it calls - :meth:`delete_instance()`. A progress indicator will be - updated if one is provided. - - Subclass should override if needed. - """ - model_title_plural = self.get_model_title_plural() - - def delete(obj, i): - if self.is_deletable(obj): - self.delete_instance(obj) - - self.app.progress_loop(delete, data, progress, - message=f"Deleting {model_title_plural}") - - def delete_bulk_make_button(self): - """ """ - route_prefix = self.get_route_prefix() - - label = HTML.literal( - '{{ deleteResultsSubmitting ? "Working, please wait..." : "Delete Results" }}') - button = self.make_button(label, - variant='is-danger', - icon_left='trash', - **{'@click': 'deleteResultsSubmit()', - ':disabled': 'deleteResultsDisabled'}) - - form = HTML.tag('form', - method='post', - action=self.request.route_url(f'{route_prefix}.delete_bulk'), - ref='deleteResultsForm', - class_='control', - c=[ - render_csrf_token(self.request), - button, - ]) - return form - ############################## # autocomplete methods ############################## @@ -858,166 +700,6 @@ class MasterView(View): 'label': str(obj), } - ############################## - # download methods - ############################## - - def download(self): - """ - View to download a file associated with a model record. - - This usually corresponds to a URL like - ``/widgets/XXX/download`` where ``XXX`` represents the key/ID - for the record. - - By default, this view is included only if :attr:`downloadable` - is true. - - This method will (try to) locate the file on disk, and return - it as a file download response to the client. - - The GET request for this view may contain a ``filename`` query - string parameter, which can be used to locate one of various - files associated with the model record. This filename is - passed to :meth:`download_path()` for locating the file. - - For instance: ``/widgets/XXX/download?filename=widget-specs.txt`` - - Subclass normally should not override this method, but rather - one of the related methods which are called (in)directly by - this one: - - * :meth:`download_path()` - """ - obj = self.get_instance() - filename = self.request.GET.get('filename', None) - - path = self.download_path(obj, filename) - if not path or not os.path.exists(path): - return self.notfound() - - return self.file_response(path) - - def download_path(self, obj, filename): - """ - Should return absolute path on disk, for the given object and - filename. Result will be used to return a file response to - client. This is called by :meth:`download()`. - - Default logic always returns ``None``; subclass must override. - - :param obj: Refefence to the model instance. - - :param filename: Name of file for which to retrieve the path. - - :returns: Path to file, or ``None`` if not found. - - Note that ``filename`` may be ``None`` in which case the "default" - file path should be returned, if applicable. - - If this method returns ``None`` (as it does by default) then - the :meth:`download()` view will return a 404 not found - response. - """ - - ############################## - # execute methods - ############################## - - def execute(self): - """ - View to "execute" a model record. Requires a POST request. - - This usually corresponds to a URL like - ``/widgets/XXX/execute`` where ``XXX`` represents the key/ID - for the record. - - By default, this view is included only if :attr:`executable` is - true. - - Probably this is a "rare" view to implement for a model. But - there are two notable use cases so far, namely: - - * upgrades (cf. :class:`~wuttaweb.views.upgrades.UpgradeView`) - * batches (not yet implemented; - cf. :doc:`rattail-manual:data/batch/index` in Rattail - Manual) - - The general idea is to take some "irrevocable" action - associated with the model record. In the case of upgrades, it - is to run the upgrade script. For batches it is to "push - live" the data held within the batch. - - Subclass normally should not override this method, but rather - one of the related methods which are called (in)directly by - this one: - - * :meth:`execute_instance()` - """ - route_prefix = self.get_route_prefix() - model_title = self.get_model_title() - obj = self.get_instance() - - # make the progress tracker - progress = self.make_progress(f'{route_prefix}.execute', - success_msg=f"{model_title} was executed.", - success_url=self.get_action_url('view', obj)) - - # start thread for execute; show progress page - key = self.request.matchdict - thread = threading.Thread(target=self.execute_thread, - args=(key, self.request.user.uuid), - kwargs={'progress': progress}) - thread.start() - return self.render_progress(progress, context={ - 'instance': obj, - }, template=self.execute_progress_template) - - def execute_instance(self, obj, user, progress=None): - """ - Perform the actual "execution" logic for a model record. - Called by :meth:`execute()`. - - This method does nothing by default; subclass must override. - - :param obj: Reference to the model instance. - - :param user: Reference to the - :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who - is doing the execute. - - :param progress: Optional progress indicator factory. - """ - - def execute_thread(self, key, user_uuid, progress=None): - """ """ - model = self.app.model - model_title = self.get_model_title() - - # nb. use new session, separate from web transaction - session = self.app.make_session() - - # fetch model instance and user for this session - obj = self.get_instance(session=session, matchdict=key) - user = session.get(model.User, user_uuid) - - try: - self.execute_instance(obj, user, progress=progress) - - except Exception as error: - session.rollback() - log.warning("%s failed to execute: %s", model_title, obj, exc_info=True) - if progress: - progress.handle_error(error) - - else: - session.commit() - if progress: - progress.handle_success() - - finally: - session.close() - ############################## # configure methods ############################## @@ -1358,56 +1040,6 @@ class MasterView(View): fmt = f"${{:0,.{scale}f}}" return fmt.format(value) - def grid_render_datetime(self, record, key, value, fmt=None): - """ - Custom grid value renderer for - :class:`~python:datetime.datetime` fields. - - :param fmt: Optional format string to use instead of the - default: ``'%Y-%m-%d %I:%M:%S %p'`` - - To use this feature for your grid:: - - grid.set_renderer('my_datetime_field', self.grid_render_datetime) - - # you can also override format - grid.set_renderer('my_datetime_field', self.grid_render_datetime, - fmt='%Y-%m-%d %H:%M:%S') - """ - # nb. get new value since the one provided will just be a - # (json-safe) *string* if the original type was datetime - value = record[key] - - if value is None: - return - - return value.strftime(fmt or '%Y-%m-%d %I:%M:%S %p') - - def grid_render_enum(self, record, key, value, enum=None): - """ - Custom grid value renderer for "enum" fields. - - :param enum: Enum class for the field. This should be an - instance of :class:`~python:enum.Enum`. - - To use this feature for your grid:: - - from enum import Enum - - class MyEnum(Enum): - ONE = 1 - TWO = 2 - THREE = 3 - - grid.set_renderer('my_enum_field', self.grid_render_enum, enum=MyEnum) - """ - if enum: - original = record[key] - if original: - return original.name - - return value - def grid_render_notes(self, record, key, value, maxlen=100): """ Custom grid value renderer for "notes" fields. @@ -1486,97 +1118,6 @@ class MasterView(View): return True return False - def make_button( - self, - label, - variant=None, - primary=False, - **kwargs, - ): - """ - Make and return a HTML ``<b-button>`` literal. - - :param label: Text label for the button. - - :param variant: This is the "Buefy type" (or "Oruga variant") - for the button. Buefy and Oruga represent this differently - but this logic expects the Buefy format - (e.g. ``is-danger``) and *not* the Oruga format - (e.g. ``danger``), despite the param name matching Oruga's - terminology. - - :param type: This param is not advertised in the method - signature, but if caller specifies ``type`` instead of - ``variant`` it should work the same. - - :param primary: If neither ``variant`` nor ``type`` are - specified, this flag may be used to automatically set the - Buefy type to ``is-primary``. - - This is the preferred method where applicable, since it - avoids the Buefy vs. Oruga confusion, and the - implementation can change in the future. - - :param \**kwargs: All remaining kwargs are passed to the - underlying ``HTML.tag()`` call, so will be rendered as - attributes on the button tag. - - :returns: HTML literal for the button element. Will be something - along the lines of: - - .. code-block:: - - <b-button type="is-primary" - icon-pack="fas" - icon-left="hand-pointer"> - Click Me - </b-button> - """ - btn_kw = kwargs - btn_kw.setdefault('c', label) - btn_kw.setdefault('icon_pack', 'fas') - - if 'type' not in btn_kw: - if variant: - btn_kw['type'] = variant - elif primary: - btn_kw['type'] = 'is-primary' - - return HTML.tag('b-button', **btn_kw) - - def make_progress(self, key, **kwargs): - """ - Create and return a - :class:`~wuttaweb.progress.SessionProgress` instance, with the - given key. - - This is normally done just before calling - :meth:`render_progress()`. - """ - return SessionProgress(self.request, key, **kwargs) - - def render_progress(self, progress, context=None, template=None): - """ - Render the progress page, with given template/context. - - When a view method needs to start a long-running operation, it - first starts a thread to do the work, and then it renders the - "progress" page. As the operation continues the progress page - is updated. When the operation completes (or fails) the user - is redirected to the final destination. - - TODO: should document more about how to do this.. - - :param progress: Progress indicator instance as returned by - :meth:`make_progress()`. - - :returns: A :term:`response` with rendered progress page. - """ - template = template or '/progress.mako' - context = context or {} - context['progress'] = progress - return render_to_response(template, context, request=self.request) - def render_to_response(self, template, context): """ Locate and render an appropriate template, with the given @@ -1787,14 +1328,6 @@ class MasterView(View): kwargs['actions'] = actions - if 'tools' not in kwargs: - tools = [] - - if self.deletable_bulk and self.has_perm('delete_bulk'): - tools.append(('delete-results', self.delete_bulk_make_button())) - - kwargs['tools'] = tools - if hasattr(self, 'grid_row_class'): kwargs.setdefault('row_class', self.grid_row_class) kwargs.setdefault('filterable', self.filterable) @@ -1888,69 +1421,23 @@ class MasterView(View): # for key in self.get_model_key(): # grid.set_link(key) - def get_instance(self, session=None, matchdict=None): + def get_instance(self, session=None): """ - This should return the appropriate model instance, based on - the ``matchdict`` of model keys. + This should return the "current" model instance based on the + request details (e.g. route kwargs). - Normally this is called with no arguments, in which case the - :attr:`pyramid:pyramid.request.Request.matchdict` is used, and - will return the "current" model instance based on the request - (route/params). + If the instance cannot be found, this should raise a HTTP 404 + exception, i.e. :meth:`~wuttaweb.views.base.View.notfound()`. - If a ``matchdict`` is provided then that is used instead, to - obtain the model keys. In the simple/common example of a - "native" model in WuttaWeb, this would look like:: - - keys = {'uuid': '38905440630d11ef9228743af49773a4'} - obj = self.get_instance(matchdict=keys) - - Although some models may have different, possibly composite - key names to use instead. The specific keys this logic is - expecting are the same as returned by :meth:`get_model_key()`. - - If this method is unable to locate the instance, it should - raise a 404 error, - i.e. :meth:`~wuttaweb.views.base.View.notfound()`. - - Default implementation of this method should work okay for - views which define a :attr:`model_class`. For other views - however it will raise ``NotImplementedError``, so subclass - may need to define. - - .. warning:: - - If you are defining this method for a subclass, please note - this point regarding the 404 "not found" logic. - - It is *not* enough to simply *return* this 404 response, - you must explicitly *raise* the error. For instance:: - - def get_instance(self, **kwargs): - - # ..try to locate instance.. - obj = self.locate_instance_somehow() - - if not obj: - - # NB. THIS MAY NOT WORK AS EXPECTED - #return self.notfound() - - # nb. should always do this in get_instance() - raise self.notfound() - - This lets calling code not have to worry about whether or - not this method might return ``None``. It can safely - assume it will get back a model instance, or else a 404 - will kick in and control flow goes elsewhere. + There is no "sane" default logic here; subclass *must* + override or else a ``NotImplementedError`` is raised. """ model_class = self.get_model_class() if model_class: session = session or self.Session() - matchdict = matchdict or self.request.matchdict def filtr(query, model_key): - key = matchdict[model_key] + key = self.request.matchdict[model_key] query = query.filter(getattr(self.model_class, model_key) == key) return query @@ -2213,7 +1700,7 @@ class MasterView(View): Returns the model class for the view (if defined). A model class will *usually* be a SQLAlchemy mapped class, - e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`. + e.g. :class:`wuttjamaican:wuttjamaican.db.model.base.Person`. There is no default value here, but a subclass may override by assigning :attr:`model_class`. @@ -2547,6 +2034,17 @@ class MasterView(View): f'{permission_prefix}.create', f"Create new {model_title}") + # view + if cls.viewable: + instance_url_prefix = cls.get_instance_url_prefix() + config.add_route(f'{route_prefix}.view', instance_url_prefix) + config.add_view(cls, attr='view', + route_name=f'{route_prefix}.view', + permission=f'{permission_prefix}.view') + config.add_wutta_permission(permission_prefix, + f'{permission_prefix}.view', + f"View {model_title}") + # edit if cls.editable: instance_url_prefix = cls.get_instance_url_prefix() @@ -2571,18 +2069,6 @@ class MasterView(View): f'{permission_prefix}.delete', f"Delete {model_title}") - # bulk delete - if cls.deletable_bulk: - config.add_route(f'{route_prefix}.delete_bulk', - f'{url_prefix}/delete-bulk', - request_method='POST') - config.add_view(cls, attr='delete_bulk', - route_name=f'{route_prefix}.delete_bulk', - permission=f'{permission_prefix}.delete_bulk') - config.add_wutta_permission(permission_prefix, - f'{permission_prefix}.delete_bulk', - f"Delete {model_title_plural} in bulk") - # autocomplete if cls.has_autocomplete: config.add_route(f'{route_prefix}.autocomplete', @@ -2592,29 +2078,6 @@ class MasterView(View): renderer='json', permission=f'{route_prefix}.list') - # download - if cls.downloadable: - config.add_route(f'{route_prefix}.download', - f'{instance_url_prefix}/download') - config.add_view(cls, attr='download', - route_name=f'{route_prefix}.download', - permission=f'{permission_prefix}.download') - config.add_wutta_permission(permission_prefix, - f'{permission_prefix}.download', - f"Download file(s) for {model_title}") - - # execute - if cls.executable: - config.add_route(f'{route_prefix}.execute', - f'{instance_url_prefix}/execute', - request_method='POST') - config.add_view(cls, attr='execute', - route_name=f'{route_prefix}.execute', - permission=f'{permission_prefix}.execute') - config.add_wutta_permission(permission_prefix, - f'{permission_prefix}.execute', - f"Execute {model_title}") - # configure if cls.configurable: config.add_route(f'{route_prefix}.configure', @@ -2625,16 +2088,3 @@ class MasterView(View): config.add_wutta_permission(permission_prefix, f'{permission_prefix}.configure', f"Configure {model_title_plural}") - - # view - # nb. always register this one last, so it does not take - # priority over model-wide action routes, e.g. delete_bulk - if cls.viewable: - instance_url_prefix = cls.get_instance_url_prefix() - config.add_route(f'{route_prefix}.view', instance_url_prefix) - config.add_view(cls, attr='view', - route_name=f'{route_prefix}.view', - permission=f'{permission_prefix}.view') - config.add_wutta_permission(permission_prefix, - f'{permission_prefix}.view', - f"View {model_title}") diff --git a/src/wuttaweb/views/progress.py b/src/wuttaweb/views/progress.py deleted file mode 100644 index a06ebf2..0000000 --- a/src/wuttaweb/views/progress.py +++ /dev/null @@ -1,75 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# wuttaweb -- Web App for Wutta Framework -# Copyright © 2024 Lance Edgar -# -# This file is part of Wutta Framework. -# -# Wutta Framework is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) any -# later version. -# -# Wutta Framework is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Progress Views -""" - -from wuttaweb.progress import get_progress_session - - -def progress(request): - """ - View which returns JSON with current progress status. - - The URL is like ``/progress/XXX`` where ``XXX`` is the "key" to a - particular progress indicator, tied to a long-running operation. - - This key is used to lookup the progress status within the Beaker - session storage. See also - :class:`~wuttaweb.progress.SessionProgress`. - """ - key = request.matchdict['key'] - session = get_progress_session(request, key) - - # session has 'complete' flag set when operation is over - if session.get('complete'): - - # set a flash msg for user if one is defined. this is the - # time to do it since user is about to get redirected. - msg = session.get('success_msg') - if msg: - request.session.flash(msg) - - elif session.get('error'): # uh-oh - - # set an error flash msg for user. this is the time to do it - # since user is about to get redirected. - msg = session.get('error_msg', "An unspecified error occurred.") - request.session.flash(msg, 'error') - - # nb. we return the session as-is; since it is dict-like (and only - # contains relevant progress data) it can be used directly for the - # JSON response context - return session - - -def defaults(config, **kwargs): - base = globals() - - progress = kwargs.get('progress', base['progress']) - config.add_route('progress', '/progress/{key}') - config.add_view(progress, route_name='progress', renderer='json') - - -def includeme(config): - defaults(config) diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index 90a00cb..aa28416 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -124,25 +124,16 @@ class AppInfoView(MasterView): simple_settings = [ # basics - {'name': f'{self.config.appname}.app_title'}, - {'name': f'{self.config.appname}.node_type'}, - {'name': f'{self.config.appname}.node_title'}, - {'name': f'{self.config.appname}.production', + {'name': f'{self.app.appname}.app_title'}, + {'name': f'{self.app.appname}.node_type'}, + {'name': f'{self.app.appname}.node_title'}, + {'name': f'{self.app.appname}.production', 'type': bool}, # user/auth {'name': 'wuttaweb.home_redirect_to_login', 'type': bool, 'default': False}, - # email - {'name': f'{self.config.appname}.mail.send_emails', - 'type': bool, 'default': False}, - {'name': f'{self.config.appname}.email.default.sender'}, - {'name': f'{self.config.appname}.email.default.subject'}, - {'name': f'{self.config.appname}.email.default.to'}, - {'name': f'{self.config.appname}.email.feedback.subject'}, - {'name': f'{self.config.appname}.email.feedback.to'}, - ] def getval(key): @@ -211,7 +202,6 @@ class SettingView(MasterView): """ model_class = Setting model_title = "Raw Setting" - deletable_bulk = True filter_defaults = { 'name': {'active': True}, } diff --git a/src/wuttaweb/views/upgrades.py b/src/wuttaweb/views/upgrades.py deleted file mode 100644 index 03570f3..0000000 --- a/src/wuttaweb/views/upgrades.py +++ /dev/null @@ -1,364 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# wuttaweb -- Web App for Wutta Framework -# Copyright © 2024 Lance Edgar -# -# This file is part of Wutta Framework. -# -# Wutta Framework is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) any -# later version. -# -# Wutta Framework is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. -# -################################################################################ -""" -Upgrade Views -""" - -import datetime -import logging -import os -import shutil -import subprocess - -from sqlalchemy import orm - -from wuttjamaican.db.model import Upgrade -from wuttaweb.views import MasterView -from wuttaweb.forms import widgets -from wuttaweb.forms.schema import UserRef, WuttaEnum, FileDownload -from wuttaweb.progress import get_progress_session - - -log = logging.getLogger(__name__) - - -class UpgradeView(MasterView): - """ - Master view for upgrades. - - Default route prefix is ``upgrades``. - - Notable URLs provided by this class: - - * ``/upgrades/`` - * ``/upgrades/new`` - * ``/upgrades/XXX`` - * ``/upgrades/XXX/edit`` - * ``/upgrades/XXX/delete`` - """ - model_class = Upgrade - executable = True - execute_progress_template = '/upgrade.mako' - downloadable = True - configurable = True - - grid_columns = [ - 'created', - 'description', - 'status', - 'executed', - 'executed_by', - ] - - sort_defaults = ('created', 'desc') - - def configure_grid(self, g): - """ """ - super().configure_grid(g) - model = self.app.model - enum = self.app.enum - - # description - g.set_link('description') - - # created - g.set_renderer('created', self.grid_render_datetime) - - # created_by - g.set_link('created_by') - Creator = orm.aliased(model.User) - g.set_joiner('created_by', lambda q: q.join(Creator, - Creator.uuid == model.Upgrade.created_by_uuid)) - g.set_filter('created_by', Creator.username, - label="Created By Username") - - # status - g.set_renderer('status', self.grid_render_enum, enum=enum.UpgradeStatus) - - # executed - g.set_renderer('executed', self.grid_render_datetime) - - # executed_by - g.set_link('executed_by') - Executor = orm.aliased(model.User) - g.set_joiner('executed_by', lambda q: q.outerjoin(Executor, - Executor.uuid == model.Upgrade.executed_by_uuid)) - g.set_filter('executed_by', Executor.username, - label="Executed By Username") - - def grid_row_class(self, upgrade, data, i): - """ """ - enum = self.app.enum - if upgrade.status == enum.UpgradeStatus.EXECUTING: - return 'has-background-warning' - if upgrade.status == enum.UpgradeStatus.FAILURE: - return 'has-background-warning' - - def configure_form(self, f): - """ """ - super().configure_form(f) - enum = self.app.enum - upgrade = f.model_instance - - # never show these - f.remove('created_by_uuid', - 'executing', - 'executed_by_uuid') - - # sequence sanity - f.fields.set_sequence([ - 'description', - 'notes', - 'status', - 'created', - 'created_by', - 'executed', - 'executed_by', - ]) - - # created - if self.creating or self.editing: - f.remove('created') - - # created_by - if self.creating or self.editing: - f.remove('created_by') - else: - f.set_node('created_by', UserRef(self.request)) - - # notes - f.set_widget('notes', widgets.NotesWidget()) - - # status - if self.creating: - f.remove('status') - else: - f.set_node('status', WuttaEnum(self.request, enum.UpgradeStatus)) - - # executed - if self.creating or self.editing or not upgrade.executed: - f.remove('executed') - - # executed_by - if self.creating or self.editing or not upgrade.executed: - f.remove('executed_by') - else: - f.set_node('executed_by', UserRef(self.request)) - - # exit_code - if self.creating or self.editing or not upgrade.executed: - f.remove('exit_code') - - # stdout / stderr - if not (self.creating or self.editing) and upgrade.status in ( - enum.UpgradeStatus.SUCCESS, enum.UpgradeStatus.FAILURE): - - # stdout_file - f.append('stdout_file') - f.set_label('stdout_file', "STDOUT") - url = self.get_action_url('download', upgrade, _query={'filename': 'stdout.log'}) - f.set_node('stdout_file', FileDownload(self.request, url=url)) - f.set_default('stdout_file', self.get_upgrade_filepath(upgrade, 'stdout.log')) - - # stderr_file - f.append('stderr_file') - f.set_label('stderr_file', "STDERR") - url = self.get_action_url('download', upgrade, _query={'filename': 'stderr.log'}) - f.set_node('stderr_file', FileDownload(self.request, url=url)) - f.set_default('stderr_file', self.get_upgrade_filepath(upgrade, 'stderr.log')) - - def delete_instance(self, upgrade): - """ - We override this method to delete any files associated with - the upgrade, in addition to deleting the upgrade proper. - """ - path = self.get_upgrade_filepath(upgrade, create=False) - if os.path.exists(path): - shutil.rmtree(path) - - super().delete_instance(upgrade) - - def objectify(self, form): - """ """ - upgrade = super().objectify(form) - enum = self.app.enum - - # set user, status when creating - if self.creating: - upgrade.created_by = self.request.user - upgrade.status = enum.UpgradeStatus.PENDING - - return upgrade - - def download_path(self, upgrade, filename): - """ """ - if filename: - return self.get_upgrade_filepath(upgrade, filename) - - def get_upgrade_filepath(self, upgrade, filename=None, create=True): - """ """ - uuid = upgrade.uuid - path = self.app.get_appdir('data', 'upgrades', uuid[:2], uuid[2:], - create=create) - if filename: - path = os.path.join(path, filename) - return path - - def execute_instance(self, upgrade, user, progress=None): - """ - This method runs the actual upgrade. - - Default logic will get the script command from config, and run - it via shell in a subprocess. - - The ``stdout`` and ``stderr`` streams are captured to separate - log files which are then available to download. - - The upgrade itself is marked as "executed" with status of - either ``SUCCESS`` or ``FAILURE``. - """ - enum = self.app.enum - - # locate file paths - script = self.config.require(f'{self.app.appname}.upgrades.command') - stdout_path = self.get_upgrade_filepath(upgrade, 'stdout.log') - stderr_path = self.get_upgrade_filepath(upgrade, 'stderr.log') - - # record the fact that execution has begun for this upgrade - # nb. this is done in separate session to ensure it sticks, - # but also update local object to reflect the change - with self.app.short_session(commit=True) as s: - alt = s.merge(upgrade) - alt.status = enum.UpgradeStatus.EXECUTING - upgrade.status = enum.UpgradeStatus.EXECUTING - - # run the command - log.debug("running upgrade command: %s", script) - with open(stdout_path, 'wb') as stdout: - with open(stderr_path, 'wb') as stderr: - upgrade.exit_code = subprocess.call(script, shell=True, text=True, - stdout=stdout, stderr=stderr) - logger = log.warning if upgrade.exit_code != 0 else log.debug - logger("upgrade command had exit code: %s", upgrade.exit_code) - - # declare it complete - upgrade.executed = datetime.datetime.now() - upgrade.executed_by = user - if upgrade.exit_code == 0: - upgrade.status = enum.UpgradeStatus.SUCCESS - else: - upgrade.status = enum.UpgradeStatus.FAILURE - - def execute_progress(self): - """ """ - route_prefix = self.get_route_prefix() - upgrade = self.get_instance() - session = get_progress_session(self.request, f'{route_prefix}.execute') - - # session has 'complete' flag set when operation is over - if session.get('complete'): - - # set a flash msg for user if one is defined. this is the - # time to do it since user is about to get redirected. - msg = session.get('success_msg') - if msg: - self.request.session.flash(msg) - - elif session.get('error'): # uh-oh - - # set an error flash msg for user. this is the time to do it - # since user is about to get redirected. - msg = session.get('error_msg', "An unspecified error occurred.") - self.request.session.flash(msg, 'error') - - # our return value will include all from progress session - data = dict(session) - - # add whatever might be new from upgrade process STDOUT - path = self.get_upgrade_filepath(upgrade, filename='stdout.log') - offset = session.get('stdout.offset', 0) - if os.path.exists(path): - size = os.path.getsize(path) - offset - if size > 0: - # with open(path, 'rb') as f: - with open(path) as f: - f.seek(offset) - chunk = f.read(size) - # data['stdout'] = chunk.decode('utf8').replace('\n', '<br />') - data['stdout'] = chunk.replace('\n', '<br />') - session['stdout.offset'] = offset + size - session.save() - - return data - - def configure_get_simple_settings(self): - """ """ - - script = self.config.get(f'{self.app.appname}.upgrades.command') - if not script: - pass - - return [ - - # basics - {'name': f'{self.app.appname}.upgrades.command', - 'default': script}, - - ] - - @classmethod - def defaults(cls, config): - """ """ - - # nb. Upgrade may come from custom model - wutta_config = config.registry.settings['wutta_config'] - app = wutta_config.get_app() - cls.model_class = app.model.Upgrade - - cls._defaults(config) - cls._upgrade_defaults(config) - - @classmethod - def _upgrade_defaults(cls, config): - route_prefix = cls.get_route_prefix() - permission_prefix = cls.get_permission_prefix() - instance_url_prefix = cls.get_instance_url_prefix() - - # execution progress - config.add_route(f'{route_prefix}.execute_progress', - f'{instance_url_prefix}/execute/progress') - config.add_view(cls, attr='execute_progress', - route_name=f'{route_prefix}.execute_progress', - permission=f'{permission_prefix}.execute', - renderer='json') - - -def defaults(config, **kwargs): - base = globals() - - UpgradeView = kwargs.get('UpgradeView', base['UpgradeView']) - UpgradeView.defaults(config) - - -def includeme(config): - defaults(config) diff --git a/tests/forms/test_schema.py b/tests/forms/test_schema.py index 1c7680a..7538301 100644 --- a/tests/forms/test_schema.py +++ b/tests/forms/test_schema.py @@ -10,7 +10,7 @@ from sqlalchemy import orm from wuttjamaican.conf import WuttaConfig from wuttaweb.forms import schema as mod from wuttaweb.forms import widgets -from tests.util import DataTestCase, WebTestCase +from tests.util import DataTestCase class TestObjectNode(DataTestCase): @@ -47,15 +47,6 @@ class TestObjectNode(DataTestCase): self.assertIs(value, person) -class TestWuttaEnum(WebTestCase): - - def test_widget_maker(self): - enum = self.app.enum - typ = mod.WuttaEnum(self.request, enum.UpgradeStatus) - widget = typ.widget_maker() - self.assertIsInstance(widget, widgets.SelectWidget) - - class TestObjectRef(DataTestCase): def setUp(self): @@ -149,17 +140,10 @@ class TestObjectRef(DataTestCase): self.session.commit() self.assertIsNotNone(person.uuid) with patch.object(mod.ObjectRef, 'model_class', new=model.Person): - - # can specify as uuid typ = mod.ObjectRef(self.request, session=self.session) value = typ.objectify(person.uuid) self.assertIs(value, person) - # or can specify object proper - typ = mod.ObjectRef(self.request, session=self.session) - value = typ.objectify(person) - self.assertIs(value, person) - # error if not found with patch.object(mod.ObjectRef, 'model_class', new=model.Person): typ = mod.ObjectRef(self.request, session=self.session) @@ -202,7 +186,11 @@ class TestObjectRef(DataTestCase): self.assertEqual(widget.values[1][1], "Betty Boop") -class TestPersonRef(WebTestCase): +class TestPersonRef(DataTestCase): + + def setUp(self): + self.setup_db() + self.request = testing.DummyRequest(wutta_config=self.config) def test_sort_query(self): typ = mod.PersonRef(self.request, session=self.session) @@ -212,43 +200,6 @@ class TestPersonRef(WebTestCase): self.assertIsInstance(sorted_query, orm.Query) self.assertIsNot(sorted_query, query) - def test_get_object_url(self): - self.pyramid_config.add_route('people.view', '/people/{uuid}') - model = self.app.model - typ = mod.PersonRef(self.request, session=self.session) - - person = model.Person(full_name="Barney Rubble") - self.session.add(person) - self.session.commit() - - url = typ.get_object_url(person) - self.assertIsNotNone(url) - self.assertIn(f'/people/{person.uuid}', url) - - -class TestUserRef(WebTestCase): - - def test_sort_query(self): - typ = mod.UserRef(self.request, session=self.session) - query = typ.get_query() - self.assertIsInstance(query, orm.Query) - sorted_query = typ.sort_query(query) - self.assertIsInstance(sorted_query, orm.Query) - self.assertIsNot(sorted_query, query) - - def test_get_object_url(self): - self.pyramid_config.add_route('users.view', '/users/{uuid}') - model = self.app.model - typ = mod.UserRef(self.request, session=self.session) - - user = model.User(username='barney') - self.session.add(user) - self.session.commit() - - url = typ.get_object_url(user) - self.assertIsNotNone(url) - self.assertIn(f'/users/{user.uuid}', url) - class TestUserRefs(DataTestCase): @@ -316,18 +267,3 @@ class TestPermissions(DataTestCase): widget = typ.widget_maker() self.assertEqual(len(widget.values), 1) self.assertEqual(widget.values[0], ('widgets.polish', "Polish the widgets")) - - -class TestFileDownload(DataTestCase): - - def setUp(self): - self.setup_db() - self.request = testing.DummyRequest(wutta_config=self.config) - - def test_widget_maker(self): - - # sanity / coverage check - typ = mod.FileDownload(self.request, url='/foo') - widget = typ.widget_maker() - self.assertIsInstance(widget, widgets.FileDownloadWidget) - self.assertEqual(widget.url, '/foo') diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py index cfa4530..62d9f0b 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -7,7 +7,7 @@ import deform from pyramid import testing from wuttaweb.forms import widgets as mod -from wuttaweb.forms.schema import FileDownload, PersonRef, RoleRefs, UserRefs, Permissions +from wuttaweb.forms.schema import PersonRef, RoleRefs, UserRefs, Permissions from tests.util import WebTestCase @@ -52,55 +52,6 @@ class TestObjectRefWidget(WebTestCase): self.assertIn('href="/foo"', html) -class TestFileDownloadWidget(WebTestCase): - - def make_field(self, node, **kwargs): - # TODO: not sure why default renderer is in use even though - # pyramid_deform was included in setup? but this works.. - kwargs.setdefault('renderer', deform.Form.default_renderer) - return deform.Field(node, **kwargs) - - def test_serialize(self): - - # nb. we let the field construct the widget via our type - # (nb. at first we do not provide a url) - node = colander.SchemaNode(FileDownload(self.request)) - field = self.make_field(node) - widget = field.widget - - # null value - html = widget.serialize(field, None, readonly=True) - self.assertNotIn('<a ', html) - self.assertIn('<span>', html) - - # path to nonexistent file - html = widget.serialize(field, '/this/path/does/not/exist', readonly=True) - self.assertNotIn('<a ', html) - self.assertIn('<span>', html) - - # path to actual file - datfile = self.write_file('data.txt', "hello\n" * 1000) - html = widget.serialize(field, datfile, readonly=True) - self.assertNotIn('<a ', html) - self.assertIn('<span>', html) - self.assertIn('data.txt', html) - self.assertIn('kB)', html) - - # path to file, w/ url - node = colander.SchemaNode(FileDownload(self.request, url='/download/blarg')) - field = self.make_field(node) - widget = field.widget - html = widget.serialize(field, datfile, readonly=True) - self.assertNotIn('<span>', html) - self.assertIn('<a href="/download/blarg">', html) - self.assertIn('data.txt', html) - self.assertIn('kB)', html) - - # nb. same readonly output even if we ask for editable - html2 = widget.serialize(field, datfile, readonly=False) - self.assertEqual(html2, html) - - class TestRoleRefsWidget(WebTestCase): def make_field(self, node, **kwargs): @@ -162,7 +113,7 @@ class TestUserRefsWidget(WebTestCase): # empty html = widget.serialize(field, set(), readonly=True) - self.assertEqual(html, '<span></span>') + self.assertIn('<b-table ', html) # with data, no actions user = model.User(username='barney') diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index f532ddf..840715e 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -254,42 +254,6 @@ class TestGrid(WebTestCase): self.assertEqual(len(grid.actions), 1) self.assertIsInstance(grid.actions[0], mod.GridAction) - def test_set_tools(self): - grid = self.make_grid() - self.assertEqual(grid.tools, {}) - - # null - grid.set_tools(None) - self.assertEqual(grid.tools, {}) - - # empty - grid.set_tools({}) - self.assertEqual(grid.tools, {}) - - # full dict is replaced - grid.tools = {'foo': 'bar'} - self.assertEqual(grid.tools, {'foo': 'bar'}) - grid.set_tools({'bar': 'baz'}) - self.assertEqual(grid.tools, {'bar': 'baz'}) - - # can specify as list of html elements - grid.set_tools(['foo', 'bar']) - self.assertEqual(len(grid.tools), 2) - self.assertEqual(list(grid.tools.values()), ['foo', 'bar']) - - def test_add_tool(self): - grid = self.make_grid() - self.assertEqual(grid.tools, {}) - - # with key - grid.add_tool('foo', key='foo') - self.assertEqual(grid.tools, {'foo': 'foo'}) - - # without key - grid.add_tool('bar') - self.assertEqual(len(grid.tools), 2) - self.assertEqual(list(grid.tools.values()), ['foo', 'bar']) - def test_get_pagesize_options(self): grid = self.make_grid() diff --git a/tests/test_progress.py b/tests/test_progress.py deleted file mode 100644 index 8bfce3c..0000000 --- a/tests/test_progress.py +++ /dev/null @@ -1,62 +0,0 @@ -# -*- coding: utf-8; -*- - -from unittest import TestCase - -from pyramid import testing -from beaker.session import Session as BeakerSession - -from wuttaweb import progress as mod - - -class TestGetBasicSession(TestCase): - - def setUp(self): - self.request = testing.DummyRequest() - - def test_basic(self): - session = mod.get_basic_session(self.request) - self.assertIsInstance(session, BeakerSession) - self.assertFalse(session.use_cookies) - - -class TestGetProgressSession(TestCase): - - def setUp(self): - self.request = testing.DummyRequest() - - def test_basic(self): - self.request.session.id = 'mockid' - session = mod.get_progress_session(self.request, 'foo') - self.assertIsInstance(session, BeakerSession) - self.assertEqual(session.id, 'mockid.progress.foo') - - -class TestSessionProgress(TestCase): - - def setUp(self): - self.request = testing.DummyRequest() - self.request.session.id = 'mockid' - - def test_error_url(self): - factory = mod.SessionProgress(self.request, 'foo', success_url='/blart') - self.assertEqual(factory.error_url, '/blart') - - def test_basic(self): - - # sanity / coverage check - factory = mod.SessionProgress(self.request, 'foo') - prog = factory("doing things", 2) - prog.update(1) - prog.update(2) - prog.handle_success() - - def test_error(self): - - # sanity / coverage check - factory = mod.SessionProgress(self.request, 'foo') - prog = factory("doing things", 2) - prog.update(1) - try: - raise RuntimeError('omg') - except Exception as error: - prog.handle_error(error) diff --git a/tests/test_util.py b/tests/test_util.py index 0f54932..021394b 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -9,13 +9,13 @@ from fanstatic import Library, Resource from pyramid import testing from wuttjamaican.conf import WuttaConfig -from wuttaweb import util as mod +from wuttaweb import util class TestFieldList(TestCase): def test_insert_before(self): - fields = mod.FieldList(['f1', 'f2']) + fields = util.FieldList(['f1', 'f2']) self.assertEqual(fields, ['f1', 'f2']) # typical @@ -29,7 +29,7 @@ class TestFieldList(TestCase): self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2', 'ZZZ']) def test_insert_after(self): - fields = mod.FieldList(['f1', 'f2']) + fields = util.FieldList(['f1', 'f2']) self.assertEqual(fields, ['f1', 'f2']) # typical @@ -42,14 +42,6 @@ class TestFieldList(TestCase): fields.insert_after('f3', 'ZZZ') self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2', 'ZZZ']) - def test_set_sequence(self): - fields = mod.FieldList(['f5', 'f1', 'f3', 'f4', 'f2']) - - # setting sequence will only "sort" for explicit fields. - # other fields remain in original order, but at the end. - fields.set_sequence(['f1', 'f2', 'f3']) - self.assertEqual(fields, ['f1', 'f2', 'f3', 'f5', 'f4']) - class TestGetLibVer(TestCase): @@ -59,153 +51,153 @@ class TestGetLibVer(TestCase): self.request.wutta_config = self.config def test_buefy_default(self): - version = mod.get_libver(self.request, 'buefy') + version = util.get_libver(self.request, 'buefy') self.assertEqual(version, 'latest') def test_buefy_custom_old(self): self.config.setdefault('wuttaweb.buefy_version', '0.9.29') - version = mod.get_libver(self.request, 'buefy') + version = util.get_libver(self.request, 'buefy') self.assertEqual(version, '0.9.29') def test_buefy_custom_old_tailbone(self): self.config.setdefault('tailbone.libver.buefy', '0.9.28') - version = mod.get_libver(self.request, 'buefy', prefix='tailbone') + version = util.get_libver(self.request, 'buefy', prefix='tailbone') self.assertEqual(version, '0.9.28') def test_buefy_custom_new(self): self.config.setdefault('wuttaweb.libver.buefy', '0.9.29') - version = mod.get_libver(self.request, 'buefy') + version = util.get_libver(self.request, 'buefy') self.assertEqual(version, '0.9.29') def test_buefy_configured_only(self): - version = mod.get_libver(self.request, 'buefy', configured_only=True) + version = util.get_libver(self.request, 'buefy', configured_only=True) self.assertIsNone(version) def test_buefy_default_only(self): self.config.setdefault('wuttaweb.libver.buefy', '0.9.29') - version = mod.get_libver(self.request, 'buefy', default_only=True) + version = util.get_libver(self.request, 'buefy', default_only=True) self.assertEqual(version, 'latest') def test_buefy_css_default(self): - version = mod.get_libver(self.request, 'buefy.css') + version = util.get_libver(self.request, 'buefy.css') self.assertEqual(version, 'latest') def test_buefy_css_custom_old(self): # nb. this uses same setting as buefy (js) self.config.setdefault('wuttaweb.buefy_version', '0.9.29') - version = mod.get_libver(self.request, 'buefy.css') + version = util.get_libver(self.request, 'buefy.css') self.assertEqual(version, '0.9.29') def test_buefy_css_custom_new(self): # nb. this uses same setting as buefy (js) self.config.setdefault('wuttaweb.libver.buefy', '0.9.29') - version = mod.get_libver(self.request, 'buefy.css') + version = util.get_libver(self.request, 'buefy.css') self.assertEqual(version, '0.9.29') def test_buefy_css_configured_only(self): - version = mod.get_libver(self.request, 'buefy.css', configured_only=True) + version = util.get_libver(self.request, 'buefy.css', configured_only=True) self.assertIsNone(version) def test_buefy_css_default_only(self): self.config.setdefault('wuttaweb.libver.buefy', '0.9.29') - version = mod.get_libver(self.request, 'buefy.css', default_only=True) + version = util.get_libver(self.request, 'buefy.css', default_only=True) self.assertEqual(version, 'latest') def test_vue_default(self): - version = mod.get_libver(self.request, 'vue') + version = util.get_libver(self.request, 'vue') self.assertEqual(version, '2.6.14') def test_vue_custom_old(self): self.config.setdefault('wuttaweb.vue_version', '3.4.31') - version = mod.get_libver(self.request, 'vue') + version = util.get_libver(self.request, 'vue') self.assertEqual(version, '3.4.31') def test_vue_custom_new(self): self.config.setdefault('wuttaweb.libver.vue', '3.4.31') - version = mod.get_libver(self.request, 'vue') + version = util.get_libver(self.request, 'vue') self.assertEqual(version, '3.4.31') def test_vue_configured_only(self): - version = mod.get_libver(self.request, 'vue', configured_only=True) + version = util.get_libver(self.request, 'vue', configured_only=True) self.assertIsNone(version) def test_vue_default_only(self): self.config.setdefault('wuttaweb.libver.vue', '3.4.31') - version = mod.get_libver(self.request, 'vue', default_only=True) + version = util.get_libver(self.request, 'vue', default_only=True) self.assertEqual(version, '2.6.14') def test_vue_resource_default(self): - version = mod.get_libver(self.request, 'vue_resource') + version = util.get_libver(self.request, 'vue_resource') self.assertEqual(version, 'latest') def test_vue_resource_custom(self): self.config.setdefault('wuttaweb.libver.vue_resource', '1.5.3') - version = mod.get_libver(self.request, 'vue_resource') + version = util.get_libver(self.request, 'vue_resource') self.assertEqual(version, '1.5.3') def test_fontawesome_default(self): - version = mod.get_libver(self.request, 'fontawesome') + version = util.get_libver(self.request, 'fontawesome') self.assertEqual(version, '5.3.1') def test_fontawesome_custom(self): self.config.setdefault('wuttaweb.libver.fontawesome', '5.6.3') - version = mod.get_libver(self.request, 'fontawesome') + version = util.get_libver(self.request, 'fontawesome') self.assertEqual(version, '5.6.3') def test_bb_vue_default(self): - version = mod.get_libver(self.request, 'bb_vue') + version = util.get_libver(self.request, 'bb_vue') self.assertEqual(version, '3.4.31') def test_bb_vue_custom(self): self.config.setdefault('wuttaweb.libver.bb_vue', '3.4.30') - version = mod.get_libver(self.request, 'bb_vue') + version = util.get_libver(self.request, 'bb_vue') self.assertEqual(version, '3.4.30') def test_bb_oruga_default(self): - version = mod.get_libver(self.request, 'bb_oruga') + version = util.get_libver(self.request, 'bb_oruga') self.assertEqual(version, '0.8.12') def test_bb_oruga_custom(self): self.config.setdefault('wuttaweb.libver.bb_oruga', '0.8.11') - version = mod.get_libver(self.request, 'bb_oruga') + version = util.get_libver(self.request, 'bb_oruga') self.assertEqual(version, '0.8.11') def test_bb_oruga_bulma_default(self): - version = mod.get_libver(self.request, 'bb_oruga_bulma') + version = util.get_libver(self.request, 'bb_oruga_bulma') self.assertEqual(version, '0.3.0') - version = mod.get_libver(self.request, 'bb_oruga_bulma_css') + version = util.get_libver(self.request, 'bb_oruga_bulma_css') self.assertEqual(version, '0.3.0') def test_bb_oruga_bulma_custom(self): self.config.setdefault('wuttaweb.libver.bb_oruga_bulma', '0.2.11') - version = mod.get_libver(self.request, 'bb_oruga_bulma') + version = util.get_libver(self.request, 'bb_oruga_bulma') self.assertEqual(version, '0.2.11') def test_bb_fontawesome_svg_core_default(self): - version = mod.get_libver(self.request, 'bb_fontawesome_svg_core') + version = util.get_libver(self.request, 'bb_fontawesome_svg_core') self.assertEqual(version, '6.5.2') def test_bb_fontawesome_svg_core_custom(self): self.config.setdefault('wuttaweb.libver.bb_fontawesome_svg_core', '6.5.1') - version = mod.get_libver(self.request, 'bb_fontawesome_svg_core') + version = util.get_libver(self.request, 'bb_fontawesome_svg_core') self.assertEqual(version, '6.5.1') def test_bb_free_solid_svg_icons_default(self): - version = mod.get_libver(self.request, 'bb_free_solid_svg_icons') + version = util.get_libver(self.request, 'bb_free_solid_svg_icons') self.assertEqual(version, '6.5.2') def test_bb_free_solid_svg_icons_custom(self): self.config.setdefault('wuttaweb.libver.bb_free_solid_svg_icons', '6.5.1') - version = mod.get_libver(self.request, 'bb_free_solid_svg_icons') + version = util.get_libver(self.request, 'bb_free_solid_svg_icons') self.assertEqual(version, '6.5.1') def test_bb_vue_fontawesome_default(self): - version = mod.get_libver(self.request, 'bb_vue_fontawesome') + version = util.get_libver(self.request, 'bb_vue_fontawesome') self.assertEqual(version, '3.0.6') def test_bb_vue_fontawesome_custom(self): self.config.setdefault('wuttaweb.libver.bb_vue_fontawesome', '3.0.8') - version = mod.get_libver(self.request, 'bb_vue_fontawesome') + version = util.get_libver(self.request, 'bb_vue_fontawesome') self.assertEqual(version, '3.0.8') @@ -246,191 +238,191 @@ class TestGetLibUrl(TestCase): self.request.script_name = '/wutta' def test_buefy_default(self): - url = mod.get_liburl(self.request, 'buefy') + url = util.get_liburl(self.request, 'buefy') self.assertEqual(url, 'https://unpkg.com/buefy@latest/dist/buefy.min.js') def test_buefy_custom(self): self.config.setdefault('wuttaweb.liburl.buefy', '/lib/buefy.js') - url = mod.get_liburl(self.request, 'buefy') + url = util.get_liburl(self.request, 'buefy') self.assertEqual(url, '/lib/buefy.js') def test_buefy_custom_tailbone(self): self.config.setdefault('tailbone.liburl.buefy', '/tailbone/buefy.js') - url = mod.get_liburl(self.request, 'buefy', prefix='tailbone') + url = util.get_liburl(self.request, 'buefy', prefix='tailbone') self.assertEqual(url, '/tailbone/buefy.js') def test_buefy_default_only(self): self.config.setdefault('wuttaweb.liburl.buefy', '/lib/buefy.js') - url = mod.get_liburl(self.request, 'buefy', default_only=True) + url = util.get_liburl(self.request, 'buefy', default_only=True) self.assertEqual(url, 'https://unpkg.com/buefy@latest/dist/buefy.min.js') def test_buefy_configured_only(self): - url = mod.get_liburl(self.request, 'buefy', configured_only=True) + url = util.get_liburl(self.request, 'buefy', configured_only=True) self.assertIsNone(url) def test_buefy_fanstatic(self): self.setup_fanstatic() - url = mod.get_liburl(self.request, 'buefy') + url = util.get_liburl(self.request, 'buefy') self.assertEqual(url, '/wutta/fanstatic/buefy.js') def test_buefy_fanstatic_tailbone(self): self.setup_fanstatic(register=False) self.config.setdefault('tailbone.static_libcache.module', 'tests.test_util') - url = mod.get_liburl(self.request, 'buefy', prefix='tailbone') + url = util.get_liburl(self.request, 'buefy', prefix='tailbone') self.assertEqual(url, '/wutta/fanstatic/buefy.js') def test_buefy_css_default(self): - url = mod.get_liburl(self.request, 'buefy.css') + url = util.get_liburl(self.request, 'buefy.css') self.assertEqual(url, 'https://unpkg.com/buefy@latest/dist/buefy.min.css') def test_buefy_css_custom(self): self.config.setdefault('wuttaweb.liburl.buefy.css', '/lib/buefy.css') - url = mod.get_liburl(self.request, 'buefy.css') + url = util.get_liburl(self.request, 'buefy.css') self.assertEqual(url, '/lib/buefy.css') def test_buefy_css_fanstatic(self): self.setup_fanstatic() - url = mod.get_liburl(self.request, 'buefy.css') + url = util.get_liburl(self.request, 'buefy.css') self.assertEqual(url, '/wutta/fanstatic/buefy.css') def test_vue_default(self): - url = mod.get_liburl(self.request, 'vue') + url = util.get_liburl(self.request, 'vue') self.assertEqual(url, 'https://unpkg.com/vue@2.6.14/dist/vue.min.js') def test_vue_custom(self): self.config.setdefault('wuttaweb.liburl.vue', '/lib/vue.js') - url = mod.get_liburl(self.request, 'vue') + url = util.get_liburl(self.request, 'vue') self.assertEqual(url, '/lib/vue.js') def test_vue_fanstatic(self): self.setup_fanstatic() - url = mod.get_liburl(self.request, 'vue') + url = util.get_liburl(self.request, 'vue') self.assertEqual(url, '/wutta/fanstatic/vue.js') def test_vue_resource_default(self): - url = mod.get_liburl(self.request, 'vue_resource') + url = util.get_liburl(self.request, 'vue_resource') self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/vue-resource@latest') def test_vue_resource_custom(self): self.config.setdefault('wuttaweb.liburl.vue_resource', '/lib/vue-resource.js') - url = mod.get_liburl(self.request, 'vue_resource') + url = util.get_liburl(self.request, 'vue_resource') self.assertEqual(url, '/lib/vue-resource.js') def test_vue_resource_fanstatic(self): self.setup_fanstatic() - url = mod.get_liburl(self.request, 'vue_resource') + url = util.get_liburl(self.request, 'vue_resource') self.assertEqual(url, '/wutta/fanstatic/vue_resource.js') def test_fontawesome_default(self): - url = mod.get_liburl(self.request, 'fontawesome') + url = util.get_liburl(self.request, 'fontawesome') self.assertEqual(url, 'https://use.fontawesome.com/releases/v5.3.1/js/all.js') def test_fontawesome_custom(self): self.config.setdefault('wuttaweb.liburl.fontawesome', '/lib/fontawesome.js') - url = mod.get_liburl(self.request, 'fontawesome') + url = util.get_liburl(self.request, 'fontawesome') self.assertEqual(url, '/lib/fontawesome.js') def test_fontawesome_fanstatic(self): self.setup_fanstatic() - url = mod.get_liburl(self.request, 'fontawesome') + url = util.get_liburl(self.request, 'fontawesome') self.assertEqual(url, '/wutta/fanstatic/fontawesome.js') def test_bb_vue_default(self): - url = mod.get_liburl(self.request, 'bb_vue') + url = util.get_liburl(self.request, 'bb_vue') self.assertEqual(url, 'https://unpkg.com/vue@3.4.31/dist/vue.esm-browser.prod.js') def test_bb_vue_custom(self): self.config.setdefault('wuttaweb.liburl.bb_vue', '/lib/vue.js') - url = mod.get_liburl(self.request, 'bb_vue') + url = util.get_liburl(self.request, 'bb_vue') self.assertEqual(url, '/lib/vue.js') def test_bb_vue_fanstatic(self): self.setup_fanstatic() - url = mod.get_liburl(self.request, 'bb_vue') + url = util.get_liburl(self.request, 'bb_vue') self.assertEqual(url, '/wutta/fanstatic/bb_vue.js') def test_bb_oruga_default(self): - url = mod.get_liburl(self.request, 'bb_oruga') + url = util.get_liburl(self.request, 'bb_oruga') self.assertEqual(url, 'https://unpkg.com/@oruga-ui/oruga-next@0.8.12/dist/oruga.mjs') def test_bb_oruga_custom(self): self.config.setdefault('wuttaweb.liburl.bb_oruga', '/lib/oruga.js') - url = mod.get_liburl(self.request, 'bb_oruga') + url = util.get_liburl(self.request, 'bb_oruga') self.assertEqual(url, '/lib/oruga.js') def test_bb_oruga_fanstatic(self): self.setup_fanstatic() - url = mod.get_liburl(self.request, 'bb_oruga') + url = util.get_liburl(self.request, 'bb_oruga') self.assertEqual(url, '/wutta/fanstatic/bb_oruga.js') def test_bb_oruga_bulma_default(self): - url = mod.get_liburl(self.request, 'bb_oruga_bulma') + url = util.get_liburl(self.request, 'bb_oruga_bulma') self.assertEqual(url, 'https://unpkg.com/@oruga-ui/theme-bulma@0.3.0/dist/bulma.mjs') def test_bb_oruga_bulma_custom(self): self.config.setdefault('wuttaweb.liburl.bb_oruga_bulma', '/lib/oruga_bulma.js') - url = mod.get_liburl(self.request, 'bb_oruga_bulma') + url = util.get_liburl(self.request, 'bb_oruga_bulma') self.assertEqual(url, '/lib/oruga_bulma.js') def test_bb_oruga_bulma_fanstatic(self): self.setup_fanstatic() - url = mod.get_liburl(self.request, 'bb_oruga_bulma') + url = util.get_liburl(self.request, 'bb_oruga_bulma') self.assertEqual(url, '/wutta/fanstatic/bb_oruga_bulma.js') def test_bb_oruga_bulma_css_default(self): - url = mod.get_liburl(self.request, 'bb_oruga_bulma_css') + url = util.get_liburl(self.request, 'bb_oruga_bulma_css') self.assertEqual(url, 'https://unpkg.com/@oruga-ui/theme-bulma@0.3.0/dist/bulma.css') def test_bb_oruga_bulma_css_custom(self): self.config.setdefault('wuttaweb.liburl.bb_oruga_bulma_css', '/lib/oruga-bulma.css') - url = mod.get_liburl(self.request, 'bb_oruga_bulma_css') + url = util.get_liburl(self.request, 'bb_oruga_bulma_css') self.assertEqual(url, '/lib/oruga-bulma.css') def test_bb_oruga_bulma_css_fanstatic(self): self.setup_fanstatic() - url = mod.get_liburl(self.request, 'bb_oruga_bulma_css') + url = util.get_liburl(self.request, 'bb_oruga_bulma_css') self.assertEqual(url, '/wutta/fanstatic/bb_oruga_bulma.css') def test_bb_fontawesome_svg_core_default(self): - url = mod.get_liburl(self.request, 'bb_fontawesome_svg_core') + url = util.get_liburl(self.request, 'bb_fontawesome_svg_core') self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-svg-core@6.5.2/+esm') def test_bb_fontawesome_svg_core_custom(self): self.config.setdefault('wuttaweb.liburl.bb_fontawesome_svg_core', '/lib/fontawesome-svg-core.js') - url = mod.get_liburl(self.request, 'bb_fontawesome_svg_core') + url = util.get_liburl(self.request, 'bb_fontawesome_svg_core') self.assertEqual(url, '/lib/fontawesome-svg-core.js') def test_bb_fontawesome_svg_core_fanstatic(self): self.setup_fanstatic() - url = mod.get_liburl(self.request, 'bb_fontawesome_svg_core') + url = util.get_liburl(self.request, 'bb_fontawesome_svg_core') self.assertEqual(url, '/wutta/fanstatic/bb_fontawesome_svg_core.js') def test_bb_free_solid_svg_icons_default(self): - url = mod.get_liburl(self.request, 'bb_free_solid_svg_icons') + url = util.get_liburl(self.request, 'bb_free_solid_svg_icons') self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/@fortawesome/free-solid-svg-icons@6.5.2/+esm') def test_bb_free_solid_svg_icons_custom(self): self.config.setdefault('wuttaweb.liburl.bb_free_solid_svg_icons', '/lib/free-solid-svg-icons.js') - url = mod.get_liburl(self.request, 'bb_free_solid_svg_icons') + url = util.get_liburl(self.request, 'bb_free_solid_svg_icons') self.assertEqual(url, '/lib/free-solid-svg-icons.js') def test_bb_free_solid_svg_icons_fanstatic(self): self.setup_fanstatic() - url = mod.get_liburl(self.request, 'bb_free_solid_svg_icons') + url = util.get_liburl(self.request, 'bb_free_solid_svg_icons') self.assertEqual(url, '/wutta/fanstatic/bb_free_solid_svg_icons.js') def test_bb_vue_fontawesome_default(self): - url = mod.get_liburl(self.request, 'bb_vue_fontawesome') + url = util.get_liburl(self.request, 'bb_vue_fontawesome') self.assertEqual(url, 'https://cdn.jsdelivr.net/npm/@fortawesome/vue-fontawesome@3.0.6/+esm') def test_bb_vue_fontawesome_custom(self): self.config.setdefault('wuttaweb.liburl.bb_vue_fontawesome', '/lib/vue-fontawesome.js') - url = mod.get_liburl(self.request, 'bb_vue_fontawesome') + url = util.get_liburl(self.request, 'bb_vue_fontawesome') self.assertEqual(url, '/lib/vue-fontawesome.js') def test_bb_vue_fontawesome_fanstatic(self): self.setup_fanstatic() - url = mod.get_liburl(self.request, 'bb_vue_fontawesome') + url = util.get_liburl(self.request, 'bb_vue_fontawesome') self.assertEqual(url, '/wutta/fanstatic/bb_vue_fontawesome.js') @@ -447,17 +439,17 @@ class TestGetFormData(TestCase): def test_default(self): request = self.make_request() - data = mod.get_form_data(request) + data = util.get_form_data(request) self.assertEqual(data, {'foo1': 'bar'}) def test_is_xhr(self): request = self.make_request(POST=None, is_xhr=True) - data = mod.get_form_data(request) + data = util.get_form_data(request) self.assertEqual(data, {'foo2': 'baz'}) def test_content_type(self): request = self.make_request(POST=None, content_type='application/json') - data = mod.get_form_data(request) + data = util.get_form_data(request) self.assertEqual(data, {'foo2': 'baz'}) @@ -468,16 +460,16 @@ class TestGetModelFields(TestCase): self.app = self.config.get_app() def test_empty_model_class(self): - fields = mod.get_model_fields(self.config) + fields = util.get_model_fields(self.config) self.assertIsNone(fields) def test_unknown_model_class(self): - fields = mod.get_model_fields(self.config, TestCase) + fields = util.get_model_fields(self.config, TestCase) self.assertIsNone(fields) def test_basic(self): model = self.app.model - fields = mod.get_model_fields(self.config, model.Setting) + fields = util.get_model_fields(self.config, model.Setting) self.assertEqual(fields, ['name', 'value']) @@ -492,9 +484,9 @@ class TestGetCsrfToken(TestCase): # same token returned for same request # TODO: dummy request is always returning same token! # so this isn't really testing anything.. :( - first = mod.get_csrf_token(self.request) + first = util.get_csrf_token(self.request) self.assertIsNotNone(first) - second = mod.get_csrf_token(self.request) + second = util.get_csrf_token(self.request) self.assertEqual(first, second) # TODO: ideally would make a new request here and confirm it @@ -505,7 +497,7 @@ class TestGetCsrfToken(TestCase): # nb. dummy request always returns same token, so must # trick it into thinking it doesn't have one yet with patch.object(self.request.session, 'get_csrf_token', return_value=None): - token = mod.get_csrf_token(self.request) + token = util.get_csrf_token(self.request) self.assertIsNotNone(token) @@ -516,10 +508,10 @@ class TestRenderCsrfToken(TestCase): self.request = testing.DummyRequest(wutta_config=self.config) def test_basics(self): - html = mod.render_csrf_token(self.request) + html = util.render_csrf_token(self.request) self.assertIn('type="hidden"', html) self.assertIn('name="_csrf"', html) - token = mod.get_csrf_token(self.request) + token = util.get_csrf_token(self.request) self.assertIn(f'value="{token}"', html) @@ -530,17 +522,17 @@ class TestMakeJsonSafe(TestCase): self.app = self.config.get_app() def test_null(self): - value = mod.make_json_safe(colander.null) + value = util.make_json_safe(colander.null) self.assertIsNone(value) - value = mod.make_json_safe(None) + value = util.make_json_safe(None) self.assertIsNone(value) def test_invalid(self): model = self.app.model person = model.Person(full_name="Betty Boop") self.assertRaises(TypeError, json.dumps, person) - value = mod.make_json_safe(person, key='person') + value = util.make_json_safe(person, key='person') self.assertEqual(value, "Betty Boop") def test_dict(self): @@ -553,7 +545,7 @@ class TestMakeJsonSafe(TestCase): } self.assertRaises(TypeError, json.dumps, data) - value = mod.make_json_safe(data) + value = util.make_json_safe(data) self.assertEqual(value, { 'foo': 'bar', 'person': "Betty Boop", diff --git a/tests/util.py b/tests/util.py index 51a5768..ab31dd4 100644 --- a/tests/util.py +++ b/tests/util.py @@ -6,12 +6,11 @@ from unittest.mock import MagicMock from pyramid import testing from wuttjamaican.conf import WuttaConfig -from wuttjamaican.testing import FileConfigTestCase from wuttaweb import subscribers from wuttaweb.menus import MenuHandler -class DataTestCase(FileConfigTestCase): +class DataTestCase(TestCase): """ Base class for test suites requiring a full (typical) database. """ @@ -20,7 +19,6 @@ class DataTestCase(FileConfigTestCase): self.setup_db() def setup_db(self): - self.setup_files() self.config = WuttaConfig(defaults={ 'wutta.db.default.url': 'sqlite://', }) @@ -35,7 +33,7 @@ class DataTestCase(FileConfigTestCase): self.teardown_db() def teardown_db(self): - self.teardown_files() + pass class WebTestCase(DataTestCase): diff --git a/tests/views/test_base.py b/tests/views/test_base.py index 9601212..f86fc8f 100644 --- a/tests/views/test_base.py +++ b/tests/views/test_base.py @@ -50,26 +50,6 @@ class TestView(WebTestCase): self.assertIsInstance(error, HTTPFound) self.assertEqual(error.location, '/') - def test_file_response(self): - view = self.make_view() - - # default uses attachment behavior - datfile = self.write_file('dat.txt', 'hello') - response = view.file_response(datfile) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.content_disposition, 'attachment; filename="dat.txt"') - - # but can disable attachment behavior - datfile = self.write_file('dat.txt', 'hello') - response = view.file_response(datfile, attachment=False) - self.assertEqual(response.status_code, 200) - self.assertIsNone(response.content_disposition) - - # path not found - crapfile = '/does/not/exist' - response = view.file_response(crapfile) - self.assertEqual(response.status_code, 404) - def test_json_response(self): view = self.make_view() response = view.json_response({'foo': 'bar'}) diff --git a/tests/views/test_common.py b/tests/views/test_common.py index 0da2822..bf240b5 100644 --- a/tests/views/test_common.py +++ b/tests/views/test_common.py @@ -1,9 +1,5 @@ # -*- coding: utf-8; -*- -from unittest.mock import patch - -import colander - from wuttaweb.views import common as mod from tests.util import WebTestCase @@ -55,78 +51,6 @@ class TestCommonView(WebTestCase): context = view.home(session=self.session) self.assertEqual(context['index_title'], self.app.get_title()) - def test_feedback_make_schema(self): - view = self.make_view() - schema = view.feedback_make_schema() - self.assertIsInstance(schema, colander.Schema) - self.assertIn('message', schema) - - def test_feedback(self): - self.pyramid_config.add_route('users.view', '/users/{uuid}') - model = self.app.model - user = model.User(username='barney') - self.session.add(user) - self.session.commit() - - view = self.make_view() - with patch.object(view, 'feedback_send') as feedback_send: - - # basic send, no user - self.request.client_addr = '127.0.0.1' - self.request.method = 'POST' - self.request.POST = { - 'referrer': '/foo', - 'user_name': "Barney Rubble", - 'message': "hello world", - } - context = view.feedback() - self.assertEqual(context, {'ok': True}) - feedback_send.assert_called_once() - - # reset - feedback_send.reset_mock() - - # basic send, with user - self.request.user = user - self.request.POST['user_uuid'] = user.uuid - with patch.object(mod, 'Session', return_value=self.session): - context = view.feedback() - self.assertEqual(context, {'ok': True}) - feedback_send.assert_called_once() - - # reset - self.request.user = None - feedback_send.reset_mock() - - # invalid form data - self.request.POST = {'message': 'hello world'} - context = view.feedback() - self.assertEqual(list(context), ['error']) - self.assertIn('Required', context['error']) - feedback_send.assert_not_called() - - # error on send - self.request.POST = { - 'referrer': '/foo', - 'user_name': "Barney Rubble", - 'message': "hello world", - } - feedback_send.side_effect = RuntimeError - context = view.feedback() - feedback_send.assert_called_once() - self.assertEqual(list(context), ['error']) - self.assertIn('RuntimeError', context['error']) - - def test_feedback_send(self): - view = self.make_view() - with patch.object(self.app, 'send_email') as send_email: - view.feedback_send({'user_name': "Barney", - 'message': "hello world"}) - send_email.assert_called_once_with('feedback', { - 'user_name': "Barney", - 'message': "hello world" - }) - def test_setup(self): self.pyramid_config.add_route('home', '/') self.pyramid_config.add_route('login', '/login') diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 7b75e0a..023449a 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -1,6 +1,5 @@ # -*- coding: utf-8; -*- -import datetime import decimal import functools from unittest import TestCase @@ -14,7 +13,6 @@ from pyramid.httpexceptions import HTTPNotFound from wuttjamaican.conf import WuttaConfig from wuttaweb.views import master as mod from wuttaweb.views import View -from wuttaweb.progress import SessionProgress from wuttaweb.subscribers import new_request_set_user from tests.util import WebTestCase @@ -28,10 +26,7 @@ class TestMasterView(WebTestCase): with patch.multiple(mod.MasterView, create=True, model_name='Widget', model_key='uuid', - deletable_bulk=True, has_autocomplete=True, - downloadable=True, - executable=True, configurable=True): mod.MasterView.defaults(self.pyramid_config) @@ -404,49 +399,6 @@ class TestMasterView(WebTestCase): self.assertTrue(view.has_any_perm('list', 'view')) self.assertTrue(self.request.has_any_perm('settings.list', 'settings.view')) - def test_make_button(self): - view = self.make_view() - - # normal - html = view.make_button('click me') - self.assertIn('<b-button ', html) - self.assertIn('click me', html) - self.assertNotIn('is-primary', html) - - # primary as primary - html = view.make_button('click me', primary=True) - self.assertIn('<b-button ', html) - self.assertIn('click me', html) - self.assertIn('is-primary', html) - - # primary as variant - html = view.make_button('click me', variant='is-primary') - self.assertIn('<b-button ', html) - self.assertIn('click me', html) - self.assertIn('is-primary', html) - - # primary as type - html = view.make_button('click me', type='is-primary') - self.assertIn('<b-button ', html) - self.assertIn('click me', html) - self.assertIn('is-primary', html) - - def test_make_progress(self): - - # basic - view = self.make_view() - self.request.session.id = 'mockid' - progress = view.make_progress('foo') - self.assertIsInstance(progress, SessionProgress) - - def test_render_progress(self): - self.pyramid_config.add_route('progress', '/progress/{key}') - - # sanity / coverage check - view = self.make_view() - progress = MagicMock() - response = view.render_progress(progress) - def test_render_to_response(self): self.pyramid_config.include('wuttaweb.views.common') self.pyramid_config.include('wuttaweb.views.auth') @@ -520,7 +472,6 @@ class TestMasterView(WebTestCase): self.assertEqual(grid.labels, {'name': "SETTING NAME"}) def test_make_model_grid(self): - self.pyramid_config.add_route('settings.delete_bulk', '/settings/delete-bulk') model = self.app.model # no model class @@ -573,20 +524,6 @@ class TestMasterView(WebTestCase): grid = view.make_model_grid(session=self.session) self.assertEqual(len(grid.actions), 3) - # no tools by default - with patch.multiple(mod.MasterView, create=True, - model_class=model.Setting): - grid = view.make_model_grid(session=self.session) - self.assertEqual(grid.tools, {}) - - # delete-results tool added if master/perms allow - with patch.multiple(mod.MasterView, create=True, - model_class=model.Setting, - deletable_bulk=True): - with patch.object(self.request, 'is_root', new=True): - grid = view.make_model_grid(session=self.session) - self.assertIn('delete-results', grid.tools) - def test_get_grid_data(self): model = self.app.model self.app.save_setting(self.session, 'foo', 'bar') @@ -642,6 +579,7 @@ class TestMasterView(WebTestCase): self.assertEqual(value, "No") def test_grid_render_currency(self): + model = self.app.model view = self.make_view() obj = {'amount': None} @@ -659,33 +597,6 @@ class TestMasterView(WebTestCase): value = view.grid_render_currency(obj, 'amount', '-100.42') self.assertEqual(value, "($100.42)") - def test_grid_render_datetime(self): - view = self.make_view() - obj = {'dt': None} - - # null - value = view.grid_render_datetime(obj, 'dt', None) - self.assertIsNone(value) - - # normal - obj['dt'] = datetime.datetime(2024, 8, 24, 11) - value = view.grid_render_datetime(obj, 'dt', '2024-08-24T11:00:00') - self.assertEqual(value, '2024-08-24 11:00:00 AM') - - def test_grid_render_enum(self): - enum = self.app.enum - view = self.make_view() - obj = {'status': None} - - # null - value = view.grid_render_enum(obj, 'status', None, enum=enum.UpgradeStatus) - self.assertIsNone(value) - - # normal - obj['status'] = enum.UpgradeStatus.SUCCESS - value = view.grid_render_enum(obj, 'status', 'SUCCESS', enum=enum.UpgradeStatus) - self.assertEqual(value, 'SUCCESS') - def test_grid_render_notes(self): model = self.app.model view = self.make_view() @@ -1115,162 +1026,6 @@ class TestMasterView(WebTestCase): self.session.commit() self.assertEqual(self.session.query(model.Setting).count(), 0) - def test_delete_bulk(self): - self.pyramid_config.add_route('settings', '/settings/') - self.pyramid_config.add_route('progress', '/progress/{key}') - model = self.app.model - sample_data = [ - {'name': 'foo1', 'value': 'ONE'}, - {'name': 'foo2', 'value': 'two'}, - {'name': 'foo3', 'value': 'three'}, - {'name': 'foo4', 'value': 'four'}, - {'name': 'foo5', 'value': 'five'}, - {'name': 'foo6', 'value': 'six'}, - {'name': 'foo7', 'value': 'seven'}, - {'name': 'foo8', 'value': 'eight'}, - {'name': 'foo9', 'value': 'nine'}, - ] - for setting in sample_data: - self.app.save_setting(self.session, setting['name'], setting['value']) - self.session.commit() - sample_query = self.session.query(model.Setting) - - with patch.multiple(mod.MasterView, create=True, - model_class=model.Setting): - view = self.make_view() - - # sanity check on sample data - grid = view.make_model_grid(session=self.session) - data = grid.get_visible_data() - self.assertEqual(len(data), 9) - - # and then let's filter it a little - self.request.GET = {'value': 's', 'value.verb': 'contains'} - grid = view.make_model_grid(session=self.session) - self.assertEqual(len(grid.filters), 2) - self.assertEqual(len(grid.active_filters), 1) - data = grid.get_visible_data() - self.assertEqual(len(data), 2) - - # okay now let's delete those via quick method - # (user should be redirected back to index) - with patch.multiple(view, - deletable_bulk_quick=True, - make_model_grid=MagicMock(return_value=grid)): - response = view.delete_bulk() - self.assertEqual(response.status_code, 302) - self.assertEqual(self.session.query(model.Setting).count(), 7) - - # now use another filter since those records are gone - self.request.GET = {'name': 'foo2', 'name.verb': 'equal'} - grid = view.make_model_grid(session=self.session) - self.assertEqual(len(grid.filters), 2) - self.assertEqual(len(grid.active_filters), 1) - data = grid.get_visible_data() - self.assertEqual(len(data), 1) - - # this time we delete "slowly" with progress - self.request.session.id = 'ignorethis' - with patch.multiple(view, - deletable_bulk_quick=False, - make_model_grid=MagicMock(return_value=grid)): - with patch.object(mod, 'threading') as threading: - response = view.delete_bulk() - threading.Thread.return_value.start.assert_called_once_with() - # nb. user is shown progress page - self.assertEqual(response.status_code, 200) - - def test_delete_bulk_action(self): - self.pyramid_config.add_route('settings', '/settings/') - model = self.app.model - sample_data = [ - {'name': 'foo1', 'value': 'ONE'}, - {'name': 'foo2', 'value': 'two'}, - {'name': 'foo3', 'value': 'three'}, - {'name': 'foo4', 'value': 'four'}, - {'name': 'foo5', 'value': 'five'}, - {'name': 'foo6', 'value': 'six'}, - {'name': 'foo7', 'value': 'seven'}, - {'name': 'foo8', 'value': 'eight'}, - {'name': 'foo9', 'value': 'nine'}, - ] - for setting in sample_data: - self.app.save_setting(self.session, setting['name'], setting['value']) - self.session.commit() - sample_query = self.session.query(model.Setting) - - with patch.multiple(mod.MasterView, create=True, - model_class=model.Setting): - view = self.make_view() - - # basic bulk delete - self.assertEqual(self.session.query(model.Setting).count(), 9) - settings = self.session.query(model.Setting)\ - .filter(model.Setting.value.ilike('%s%'))\ - .all() - self.assertEqual(len(settings), 2) - view.delete_bulk_action(settings) - self.session.commit() - self.assertEqual(self.session.query(model.Setting).count(), 7) - - def test_delete_bulk_thread(self): - self.pyramid_config.add_route('settings', '/settings/') - model = self.app.model - sample_data = [ - {'name': 'foo1', 'value': 'ONE'}, - {'name': 'foo2', 'value': 'two'}, - {'name': 'foo3', 'value': 'three'}, - {'name': 'foo4', 'value': 'four'}, - {'name': 'foo5', 'value': 'five'}, - {'name': 'foo6', 'value': 'six'}, - {'name': 'foo7', 'value': 'seven'}, - {'name': 'foo8', 'value': 'eight'}, - {'name': 'foo9', 'value': 'nine'}, - ] - for setting in sample_data: - self.app.save_setting(self.session, setting['name'], setting['value']) - self.session.commit() - sample_query = self.session.query(model.Setting) - - with patch.multiple(mod.MasterView, create=True, - model_class=model.Setting): - view = self.make_view() - - # basic delete, no progress - self.assertEqual(self.session.query(model.Setting).count(), 9) - settings = self.session.query(model.Setting)\ - .filter(model.Setting.value.ilike('%s%')) - self.assertEqual(settings.count(), 2) - with patch.object(self.app, 'make_session', return_value=self.session): - view.delete_bulk_thread(settings) - self.assertEqual(self.session.query(model.Setting).count(), 7) - - # basic delete, with progress - settings = self.session.query(model.Setting)\ - .filter(model.Setting.name == 'foo1') - self.assertEqual(settings.count(), 1) - with patch.object(self.app, 'make_session', return_value=self.session): - view.delete_bulk_thread(settings, progress=MagicMock()) - self.assertEqual(self.session.query(model.Setting).count(), 6) - - # error, no progress - settings = self.session.query(model.Setting)\ - .filter(model.Setting.name == 'foo2') - self.assertEqual(settings.count(), 1) - with patch.object(self.app, 'make_session', return_value=self.session): - with patch.object(view, 'delete_bulk_action', side_effect=RuntimeError): - view.delete_bulk_thread(settings) - # nb. nothing was deleted - self.assertEqual(self.session.query(model.Setting).count(), 6) - - # error, with progress - self.assertEqual(settings.count(), 1) - with patch.object(self.app, 'make_session', return_value=self.session): - with patch.object(view, 'delete_bulk_action', side_effect=RuntimeError): - view.delete_bulk_thread(settings, progress=MagicMock()) - # nb. nothing was deleted - self.assertEqual(self.session.query(model.Setting).count(), 6) - def test_autocomplete(self): model = self.app.model @@ -1312,98 +1067,6 @@ class TestMasterView(WebTestCase): self.assertEqual(normal, {'value': 'bogus', 'label': "Betty Boop"}) - def test_download(self): - model = self.app.model - self.app.save_setting(self.session, 'foo', 'bar') - self.session.commit() - - with patch.multiple(mod.MasterView, create=True, - model_class=model.Setting, - model_key='name', - Session=MagicMock(return_value=self.session)): - view = self.make_view() - self.request.matchdict = {'name': 'foo'} - - # 404 if no filename - response = view.download() - self.assertEqual(response.status_code, 404) - - # 404 if bad filename - self.request.GET = {'filename': 'doesnotexist'} - response = view.download() - self.assertEqual(response.status_code, 404) - - # 200 if good filename - foofile = self.write_file('foo.txt', 'foo') - with patch.object(view, 'download_path', return_value=foofile): - response = view.download() - self.assertEqual(response.status_code, 200) - self.assertEqual(response.content_disposition, 'attachment; filename="foo.txt"') - - def test_execute(self): - self.pyramid_config.add_route('settings.view', '/settings/{name}') - self.pyramid_config.add_route('progress', '/progress/{key}') - model = self.app.model - self.app.save_setting(self.session, 'foo', 'bar') - user = model.User(username='barney') - self.session.add(user) - self.session.commit() - - with patch.multiple(mod.MasterView, create=True, - model_class=model.Setting, - model_key='name', - Session=MagicMock(return_value=self.session)): - view = self.make_view() - self.request.matchdict = {'name': 'foo'} - self.request.session.id = 'mockid' - self.request.user = user - - # basic usage; user is shown progress page - with patch.object(mod, 'threading') as threading: - response = view.execute() - threading.Thread.return_value.start.assert_called_once_with() - self.assertEqual(response.status_code, 200) - - def test_execute_thread(self): - model = self.app.model - enum = self.app.enum - user = model.User(username='barney') - self.session.add(user) - upgrade = model.Upgrade(description='test', created_by=user, - status=enum.UpgradeStatus.PENDING) - self.session.add(upgrade) - self.session.commit() - - with patch.multiple(mod.MasterView, create=True, - model_class=model.Upgrade): - view = self.make_view() - - # basic execute, no progress - with patch.object(view, 'execute_instance') as execute_instance: - view.execute_thread({'uuid': upgrade.uuid}, user.uuid) - execute_instance.assert_called_once() - - # basic execute, with progress - with patch.object(view, 'execute_instance') as execute_instance: - progress = MagicMock() - view.execute_thread({'uuid': upgrade.uuid}, user.uuid, progress=progress) - execute_instance.assert_called_once() - progress.handle_success.assert_called_once_with() - - # error, no progress - with patch.object(view, 'execute_instance') as execute_instance: - execute_instance.side_effect = RuntimeError - view.execute_thread({'uuid': upgrade.uuid}, user.uuid) - execute_instance.assert_called_once() - - # error, with progress - with patch.object(view, 'execute_instance') as execute_instance: - progress = MagicMock() - execute_instance.side_effect = RuntimeError - view.execute_thread({'uuid': upgrade.uuid}, user.uuid, progress=progress) - execute_instance.assert_called_once() - progress.handle_error.assert_called_once() - def test_configure(self): self.pyramid_config.include('wuttaweb.views.common') self.pyramid_config.include('wuttaweb.views.auth') diff --git a/tests/views/test_progress.py b/tests/views/test_progress.py deleted file mode 100644 index 06a67f8..0000000 --- a/tests/views/test_progress.py +++ /dev/null @@ -1,62 +0,0 @@ -# -*- coding: utf-8; -*- - -from pyramid import testing - -from wuttaweb.views import progress as mod -from wuttaweb.progress import get_progress_session -from tests.util import WebTestCase - - -class TestProgressView(WebTestCase): - - def test_includeme(self): - self.pyramid_config.include('wuttaweb.views.progress') - - def test_basic(self): - self.request.session.id = 'mockid' - self.request.matchdict = {'key': 'foo'} - - # first call with no setup, will create the progress session - # but it should be "empty" - except not really since beaker - # adds some keys by default - context = mod.progress(self.request) - self.assertIsInstance(context, dict) - - # now let's establish a progress session of our own - progsess = get_progress_session(self.request, 'bar') - progsess['maximum'] = 2 - progsess['value'] = 1 - progsess.save() - - # then call view, check results - self.request.matchdict = {'key': 'bar'} - context = mod.progress(self.request) - self.assertEqual(context['maximum'], 2) - self.assertEqual(context['value'], 1) - self.assertNotIn('complete', context) - - # now mark it as complete, check results - progsess['complete'] = True - progsess['success_msg'] = "yay!" - progsess.save() - context = mod.progress(self.request) - self.assertTrue(context['complete']) - self.assertEqual(context['success_msg'], "yay!") - - # now do that all again, with error - progsess = get_progress_session(self.request, 'baz') - progsess['maximum'] = 2 - progsess['value'] = 1 - progsess.save() - self.request.matchdict = {'key': 'baz'} - context = mod.progress(self.request) - self.assertEqual(context['maximum'], 2) - self.assertEqual(context['value'], 1) - self.assertNotIn('complete', context) - self.assertNotIn('error', context) - progsess['error'] = True - progsess['error_msg'] = "omg!" - progsess.save() - context = mod.progress(self.request) - self.assertTrue(context['error']) - self.assertEqual(context['error_msg'], "omg!") diff --git a/tests/views/test_upgrades.py b/tests/views/test_upgrades.py deleted file mode 100644 index 6c89d5b..0000000 --- a/tests/views/test_upgrades.py +++ /dev/null @@ -1,364 +0,0 @@ -# -*- coding: utf-8; -*- - -import datetime -import os -import sys -from unittest.mock import patch, MagicMock - -from wuttaweb.views import upgrades as mod -from wuttjamaican.exc import ConfigurationError -from wuttaweb.progress import get_progress_session -from tests.util import WebTestCase - - -class TestUpgradeView(WebTestCase): - - def make_view(self): - return mod.UpgradeView(self.request) - - def test_includeme(self): - self.pyramid_config.include('wuttaweb.views.upgrades') - - def test_configure_grid(self): - model = self.app.model - view = self.make_view() - - # sanity / coverage check - grid = view.make_grid(model_class=model.Upgrade) - view.configure_grid(grid) - - def test_grid_row_class(self): - model = self.app.model - enum = self.app.enum - upgrade = model.Upgrade(description="test", status=enum.UpgradeStatus.PENDING) - data = dict(upgrade) - view = self.make_view() - - self.assertIsNone(view.grid_row_class(upgrade, data, 1)) - - upgrade.status = enum.UpgradeStatus.EXECUTING - self.assertEqual(view.grid_row_class(upgrade, data, 1), 'has-background-warning') - - upgrade.status = enum.UpgradeStatus.SUCCESS - self.assertIsNone(view.grid_row_class(upgrade, data, 1)) - - upgrade.status = enum.UpgradeStatus.FAILURE - self.assertEqual(view.grid_row_class(upgrade, data, 1), 'has-background-warning') - - def test_configure_form(self): - self.pyramid_config.add_route('upgrades.download', '/upgrades/{uuid}/download') - model = self.app.model - enum = self.app.enum - user = model.User(username='barney') - self.session.add(user) - upgrade = model.Upgrade(description='test', created_by=user, - status=enum.UpgradeStatus.PENDING) - self.session.add(upgrade) - self.session.commit() - view = self.make_view() - - # some fields exist when viewing - with patch.object(view, 'viewing', new=True): - form = view.make_form(model_class=model.Upgrade, model_instance=upgrade) - self.assertIn('created', form) - view.configure_form(form) - self.assertIn('created', form) - - # but then are removed when creating - with patch.object(view, 'creating', new=True): - form = view.make_form(model_class=model.Upgrade) - self.assertIn('created', form) - view.configure_form(form) - self.assertNotIn('created', form) - - # test executed, stdout/stderr when viewing - with patch.object(view, 'viewing', new=True): - - # executed is *not* shown by default - form = view.make_form(model_class=model.Upgrade, model_instance=upgrade) - self.assertIn('executed', form) - view.configure_form(form) - self.assertNotIn('executed', form) - self.assertNotIn('stdout_file', form) - self.assertNotIn('stderr_file', form) - - # but it *is* shown if upgrade is executed - upgrade.executed = datetime.datetime.now() - upgrade.status = enum.UpgradeStatus.SUCCESS - form = view.make_form(model_class=model.Upgrade, model_instance=upgrade) - self.assertIn('executed', form) - view.configure_form(form) - self.assertIn('executed', form) - self.assertIn('stdout_file', form) - self.assertIn('stderr_file', form) - - def test_objectify(self): - model = self.app.model - enum = self.app.enum - user = model.User(username='barney') - self.session.add(user) - self.session.commit() - view = self.make_view() - - # user and status are auto-set when creating - self.request.user = user - self.request.method = 'POST' - self.request.POST = {'description': "new one"} - with patch.object(view, 'creating', new=True): - form = view.make_model_form() - self.assertTrue(form.validate()) - upgrade = view.objectify(form) - self.assertEqual(upgrade.description, "new one") - self.assertIs(upgrade.created_by, user) - self.assertEqual(upgrade.status, enum.UpgradeStatus.PENDING) - - def test_download_path(self): - model = self.app.model - enum = self.app.enum - - appdir = self.mkdir('app') - self.config.setdefault('wutta.appdir', appdir) - self.assertEqual(self.app.get_appdir(), appdir) - - user = model.User(username='barney') - upgrade = model.Upgrade(description='test', created_by=user, - status=enum.UpgradeStatus.PENDING) - self.session.add(upgrade) - self.session.commit() - - view = self.make_view() - uuid = upgrade.uuid - - # no filename - path = view.download_path(upgrade, None) - self.assertIsNone(path) - - # with filename - path = view.download_path(upgrade, 'foo.txt') - self.assertEqual(path, os.path.join(appdir, 'data', 'upgrades', - uuid[:2], uuid[2:], 'foo.txt')) - - def test_get_upgrade_filepath(self): - model = self.app.model - enum = self.app.enum - - appdir = self.mkdir('app') - self.config.setdefault('wutta.appdir', appdir) - self.assertEqual(self.app.get_appdir(), appdir) - - user = model.User(username='barney') - upgrade = model.Upgrade(description='test', created_by=user, - status=enum.UpgradeStatus.PENDING) - self.session.add(upgrade) - self.session.commit() - - view = self.make_view() - uuid = upgrade.uuid - - # no filename - path = view.get_upgrade_filepath(upgrade) - self.assertEqual(path, os.path.join(appdir, 'data', 'upgrades', - uuid[:2], uuid[2:])) - - # with filename - path = view.get_upgrade_filepath(upgrade, 'foo.txt') - self.assertEqual(path, os.path.join(appdir, 'data', 'upgrades', - uuid[:2], uuid[2:], 'foo.txt')) - - def test_delete_instance(self): - model = self.app.model - enum = self.app.enum - - appdir = self.mkdir('app') - self.config.setdefault('wutta.appdir', appdir) - self.assertEqual(self.app.get_appdir(), appdir) - - user = model.User(username='barney') - upgrade = model.Upgrade(description='test', created_by=user, - status=enum.UpgradeStatus.PENDING) - self.session.add(upgrade) - self.session.commit() - - view = self.make_view() - - # mock stdout/stderr files - upgrade_dir = view.get_upgrade_filepath(upgrade) - stdout = view.get_upgrade_filepath(upgrade, 'stdout.log') - with open(stdout, 'w') as f: - f.write('stdout') - stderr = view.get_upgrade_filepath(upgrade, 'stderr.log') - with open(stderr, 'w') as f: - f.write('stderr') - - # both upgrade and files are deleted - self.assertTrue(os.path.exists(upgrade_dir)) - self.assertTrue(os.path.exists(stdout)) - self.assertTrue(os.path.exists(stderr)) - self.assertEqual(self.session.query(model.Upgrade).count(), 1) - with patch.object(view, 'Session', return_value=self.session): - view.delete_instance(upgrade) - self.assertFalse(os.path.exists(upgrade_dir)) - self.assertFalse(os.path.exists(stdout)) - self.assertFalse(os.path.exists(stderr)) - self.assertEqual(self.session.query(model.Upgrade).count(), 0) - - def test_execute_instance(self): - model = self.app.model - enum = self.app.enum - - appdir = self.mkdir('app') - self.config.setdefault('wutta.appdir', appdir) - self.assertEqual(self.app.get_appdir(), appdir) - - user = model.User(username='barney') - upgrade = model.Upgrade(description='test', created_by=user, - status=enum.UpgradeStatus.PENDING) - self.session.add(upgrade) - self.session.commit() - - view = self.make_view() - self.request.user = user - python = sys.executable - - # script not yet confiugred - self.assertRaises(ConfigurationError, view.execute_instance, upgrade, user) - - # script w/ success - goodpy = self.write_file('good.py', """ -import sys -sys.stdout.write('hello from good.py') -sys.exit(0) -""") - self.app.save_setting(self.session, 'wutta.upgrades.command', f'{python} {goodpy}') - self.assertIsNone(upgrade.executed) - self.assertIsNone(upgrade.executed_by) - self.assertEqual(upgrade.status, enum.UpgradeStatus.PENDING) - with patch.object(view, 'Session', return_value=self.session): - with patch.object(self.config, 'usedb', new=True): - view.execute_instance(upgrade, user) - self.assertIsNotNone(upgrade.executed) - self.assertIs(upgrade.executed_by, user) - self.assertEqual(upgrade.status, enum.UpgradeStatus.SUCCESS) - with open(view.get_upgrade_filepath(upgrade, 'stdout.log')) as f: - self.assertEqual(f.read(), 'hello from good.py') - with open(view.get_upgrade_filepath(upgrade, 'stderr.log')) as f: - self.assertEqual(f.read(), '') - - # need a new record for next test - upgrade = model.Upgrade(description='test', created_by=user, - status=enum.UpgradeStatus.PENDING) - self.session.add(upgrade) - self.session.commit() - - # script w/ failure - badpy = self.write_file('bad.py', """ -import sys -sys.stderr.write('hello from bad.py') -sys.exit(42) -""") - self.app.save_setting(self.session, 'wutta.upgrades.command', f'{python} {badpy}') - self.assertIsNone(upgrade.executed) - self.assertIsNone(upgrade.executed_by) - self.assertEqual(upgrade.status, enum.UpgradeStatus.PENDING) - with patch.object(view, 'Session', return_value=self.session): - with patch.object(self.config, 'usedb', new=True): - view.execute_instance(upgrade, user) - self.assertIsNotNone(upgrade.executed) - self.assertIs(upgrade.executed_by, user) - self.assertEqual(upgrade.status, enum.UpgradeStatus.FAILURE) - with open(view.get_upgrade_filepath(upgrade, 'stdout.log')) as f: - self.assertEqual(f.read(), '') - with open(view.get_upgrade_filepath(upgrade, 'stderr.log')) as f: - self.assertEqual(f.read(), 'hello from bad.py') - - def test_execute_progress(self): - model = self.app.model - enum = self.app.enum - view = self.make_view() - - user = model.User(username='barney') - self.session.add(user) - upgrade = model.Upgrade(description='test', created_by=user, - status=enum.UpgradeStatus.PENDING) - self.session.add(upgrade) - self.session.commit() - - stdout = self.write_file('stdout.log', 'hello 001\n') - - self.request.matchdict = {'uuid': upgrade.uuid} - with patch.multiple(mod.UpgradeView, - Session=MagicMock(return_value=self.session), - get_upgrade_filepath=MagicMock(return_value=stdout)): - - # nb. this is used to identify progress tracker - self.request.session.id = 'mockid#1' - - # first call should get the full contents - context = view.execute_progress() - self.assertFalse(context.get('complete')) - self.assertFalse(context.get('error')) - # nb. newline is converted to <br> - self.assertEqual(context['stdout'], 'hello 001<br />') - - # next call should get any new contents - with open(stdout, 'a') as f: - f.write('hello 002\n') - context = view.execute_progress() - self.assertFalse(context.get('complete')) - self.assertFalse(context.get('error')) - self.assertEqual(context['stdout'], 'hello 002<br />') - - # nb. switch to a different progress tracker - self.request.session.id = 'mockid#2' - - # first call should get the full contents - context = view.execute_progress() - self.assertFalse(context.get('complete')) - self.assertFalse(context.get('error')) - self.assertEqual(context['stdout'], 'hello 001<br />hello 002<br />') - - # mark progress complete - session = get_progress_session(self.request, 'upgrades.execute') - session.load() - session['complete'] = True - session['success_msg'] = 'yay!' - session.save() - - # next call should reflect that - self.assertEqual(self.request.session.pop_flash(), []) - context = view.execute_progress() - self.assertTrue(context.get('complete')) - self.assertFalse(context.get('error')) - # nb. this is missing b/c we already got all contents - self.assertNotIn('stdout', context) - self.assertEqual(self.request.session.pop_flash(), ['yay!']) - - # nb. switch to a different progress tracker - self.request.session.id = 'mockid#3' - - # first call should get the full contents - context = view.execute_progress() - self.assertFalse(context.get('complete')) - self.assertFalse(context.get('error')) - self.assertEqual(context['stdout'], 'hello 001<br />hello 002<br />') - - # mark progress error - session = get_progress_session(self.request, 'upgrades.execute') - session.load() - session['error'] = True - session['error_msg'] = 'omg!' - session.save() - - # next call should reflect that - self.assertEqual(self.request.session.pop_flash('error'), []) - context = view.execute_progress() - self.assertFalse(context.get('complete')) - self.assertTrue(context.get('error')) - # nb. this is missing b/c we already got all contents - self.assertNotIn('stdout', context) - self.assertEqual(self.request.session.pop_flash('error'), ['omg!']) - - def test_configure_get_simple_settings(self): - # sanity/coverage check - view = self.make_view() - simple = view.configure_get_simple_settings()