From 43d335bb5ba024f147a4b4df82bf5289e9c4124a Mon Sep 17 00:00:00 2001 From: Michael Simacek Date: Fri, 8 Dec 2017 14:23:09 +0200 Subject: [PATCH] Move utility/setup functions in frontend to separate modules The modules currently have the following responsibilities: - `__init__` - only imports the other modules (including plugins) to ensure that all routes, filters etc. have been registered - `base` - initialization of the app and db, definition of the session and basic globals - `filters` - jinja2 filters for templates - `model_additions` - properties monkey-patched on top of models, such as state icons - `template_functions` - utility functions primarily aimed to be used within templates - `util` - utility functions for flash messages and ordering - `tabs` - definition of tab menu - `forms` - form definitions and utilities - `auth` - authentication - `api` - API - `views` - the actual controller part, could be further split --- aux/debug_frontend.py | 3 - koschei.wsgi | 3 - koschei/frontend/__init__.py | 137 +------------ koschei/frontend/api.py | 3 +- koschei/frontend/auth.py | 8 +- koschei/frontend/base.py | 135 ++++++++++++ koschei/frontend/filters.py | 43 ++++ koschei/frontend/forms.py | 4 +- koschei/frontend/model_additions.py | 100 +++++++++ koschei/frontend/tabs.py | 71 +++++++ koschei/frontend/template_functions.py | 94 +++++++++ koschei/frontend/util.py | 69 +++++++ koschei/frontend/views.py | 260 ++---------------------- koschei/plugins/copr_plugin/frontend.py | 8 +- test/api_test.py | 2 +- test/frontend_common.py | 12 +- test/web_test.py | 11 +- 17 files changed, 554 insertions(+), 409 deletions(-) create mode 100644 koschei/frontend/base.py create mode 100644 koschei/frontend/filters.py create mode 100644 koschei/frontend/model_additions.py create mode 100644 koschei/frontend/tabs.py create mode 100644 koschei/frontend/template_functions.py create mode 100644 koschei/frontend/util.py diff --git a/aux/debug_frontend.py b/aux/debug_frontend.py index ef30f687..48bc20c8 100755 --- a/aux/debug_frontend.py +++ b/aux/debug_frontend.py @@ -26,9 +26,6 @@ load_config(['config.cfg.template', 'aux/test-config.cfg']) from koschei.frontend import app as application -import koschei.frontend.api -import koschei.frontend.views -import koschei.frontend.auth logger = logging.getLogger("koschei.sql") logger.setLevel(logging.DEBUG) diff --git a/koschei.wsgi b/koschei.wsgi index e7ae556a..a04310aa 100644 --- a/koschei.wsgi +++ b/koschei.wsgi @@ -3,6 +3,3 @@ from koschei.config import load_config load_config(['/usr/share/koschei/config.cfg', '/etc/koschei/config-frontend.cfg']) from koschei.frontend import app as application -import koschei.frontend.api -import koschei.frontend.views -import koschei.frontend.auth diff --git a/koschei/frontend/__init__.py b/koschei/frontend/__init__.py index df8af139..7279b3da 100644 --- a/koschei/frontend/__init__.py +++ b/koschei/frontend/__init__.py @@ -18,134 +18,15 @@ from __future__ import print_function, absolute_import -import logging -import humanize +from koschei.frontend.base import app -from functools import wraps +import koschei.frontend.auth +import koschei.frontend.filters +import koschei.frontend.model_additions +import koschei.frontend.template_functions +import koschei.frontend.api +import koschei.frontend.views -from flask import Flask, abort, request, g, url_for, flash -from flask_sqlalchemy import BaseQuery, Pagination -from jinja2 import Markup -from sqlalchemy.orm import scoped_session, sessionmaker +from koschei import plugin -from koschei.session import KoscheiSession -from koschei.config import get_config -from koschei.db import Query, get_engine -from koschei.models import LogEntry - -dirs = get_config('directories') -app = Flask('koschei', template_folder=dirs['templates'], - static_folder=dirs['static_folder'], - static_url_path=dirs['static_url']) -app.config.update(get_config('flask')) - -frontend_config = get_config('frontend') - - -def flash_ack(message): - """Send flask flash with message about operation that was successfully - completed.""" - flash(message, 'success') - - -def flash_nak(message): - """Send flask flash with with message about operation that was not - completed due to an error.""" - flash(message, 'danger') - - -def flash_info(message): - """Send flask flash with informational message.""" - flash(message, 'info') - - -class FrontendQuery(Query, BaseQuery): - # pylint:disable=arguments-differ - def paginate(self, items_per_page): - try: - page = int(request.args.get('page', 1)) - except ValueError: - abort(400) - if page < 1: - abort(404) - items = self.limit(items_per_page)\ - .offset((page - 1) * items_per_page).all() - if not items and page != 1: - abort(404) - if page == 1 and len(items) < items_per_page: - total = len(items) - else: - total = self.order_by(None).count() - return Pagination(self, page, items_per_page, total, items) - -db = scoped_session(sessionmaker(autocommit=False, bind=get_engine(), - query_cls=FrontendQuery)) - - -class KoscheiFrontendSession(KoscheiSession): - db = db - log = logging.getLogger('koschei.frontend') - - def log_user_action(self, message, **kwargs): - self.db.add( - LogEntry(environment='frontend', user=g.user, message=message, **kwargs), - ) - - -session = KoscheiFrontendSession() - - -tabs = [] - - -class Tab(object): - def __init__(self, name, order=0, requires_user=False): - self.name = name - self.order = order - self.requires_user = requires_user - self.master_endpoint = None - for i, tab in enumerate(tabs): - if tab.order > order: - tabs.insert(i, self) - break - else: - tabs.append(self) - - def __call__(self, fn): - @wraps(fn) - def decorated(*args, **kwargs): - g.current_tab = self - return fn(*args, **kwargs) - return decorated - - def master(self, fn): - self.master_endpoint = fn - return self(fn) - - @property - def url(self): - name = self.master_endpoint.__name__ - if self.requires_user: - return url_for(name, username=g.user.name) - return url_for(name) - - @staticmethod - def get_tabs(): - return [t for t in tabs if t.master_endpoint and not t.requires_user] - - @staticmethod - def get_user_tabs(): - return [t for t in tabs if t.master_endpoint and t.requires_user] - - -app.jinja_env.globals['get_tabs'] = Tab.get_tabs -app.jinja_env.globals['get_user_tabs'] = Tab.get_user_tabs - -app.add_template_filter(humanize.intcomma, 'intcomma') -app.add_template_filter(humanize.naturaltime, 'naturaltime') -app.add_template_filter(humanize.naturaldelta, 'naturaldelta') - - -@app.template_filter() -def percentage(val): - return format(val * 10000, '.4f') + Markup(' ‱') +plugin.load_plugins('frontend') diff --git a/koschei/frontend/api.py b/koschei/frontend/api.py index 6b879791..ffc1f7f2 100644 --- a/koschei/frontend/api.py +++ b/koschei/frontend/api.py @@ -18,7 +18,8 @@ from flask import request, Response from sqlalchemy.sql import literal_column, case -from koschei.frontend import app, db + +from koschei.frontend.base import db, app from koschei.models import Package, Collection, Build diff --git a/koschei/frontend/auth.py b/koschei/frontend/auth.py index 27b18117..b3830715 100644 --- a/koschei/frontend/auth.py +++ b/koschei/frontend/auth.py @@ -19,13 +19,15 @@ from __future__ import print_function, absolute_import -import re import functools +import re + from flask import abort, request, session, redirect, url_for, g -from koschei.config import get_config -from koschei.frontend import app, db, flash_ack, flash_info import koschei.models as m +from koschei.config import get_config +from koschei.frontend.base import app, db +from koschei.frontend.util import flash_info, flash_ack bypass_login = get_config('bypass_login', None) user_re = get_config('frontend.auth.user_re') diff --git a/koschei/frontend/base.py b/koschei/frontend/base.py new file mode 100644 index 00000000..c5c0eede --- /dev/null +++ b/koschei/frontend/base.py @@ -0,0 +1,135 @@ +# Copyright (C) 2014-2016 Red Hat, Inc. +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Author: Michael Simacek + +from __future__ import print_function, absolute_import + +import logging + +from flask import Flask, abort, request, g +from flask_sqlalchemy import BaseQuery, Pagination +from sqlalchemy.orm import scoped_session, sessionmaker + +from koschei.config import get_config +from koschei.db import Query, get_engine +from koschei.models import LogEntry, Collection, Package, Build, ResolutionChange +from koschei.session import KoscheiSession + +dirs = get_config('directories') +app = Flask('koschei', template_folder=dirs['templates'], + static_folder=dirs['static_folder'], + static_url_path=dirs['static_url']) +app.config.update(get_config('flask')) + +frontend_config = get_config('frontend') + + +class FrontendQuery(Query, BaseQuery): + # pylint:disable=arguments-differ + def paginate(self, items_per_page): + try: + page = int(request.args.get('page', 1)) + except ValueError: + abort(400) + if page < 1: + abort(404) + items = self.limit(items_per_page)\ + .offset((page - 1) * items_per_page).all() + if not items and page != 1: + abort(404) + if page == 1 and len(items) < items_per_page: + total = len(items) + else: + total = self.order_by(None).count() + return Pagination(self, page, items_per_page, total, items) + +db = scoped_session(sessionmaker(autocommit=False, bind=get_engine(), + query_cls=FrontendQuery)) + + +class KoscheiFrontendSession(KoscheiSession): + db = db + log = logging.getLogger('koschei.frontend') + + def log_user_action(self, message, **kwargs): + self.db.add( + LogEntry(environment='frontend', user=g.user, message=message, **kwargs), + ) + + +session = KoscheiFrontendSession() + + +@app.teardown_appcontext +def shutdown_session(exception=None): + db.remove() + + +@app.context_processor +def inject_fedmenu(): + if 'fedmenu_url' in frontend_config: + return { + 'fedmenu_url': frontend_config['fedmenu_url'], + 'fedmenu_data_url': frontend_config['fedmenu_data_url'], + } + return {} + + +@app.before_request +def get_collections(): + if request.endpoint == 'static': + return + collection_name = request.args.get('collection') + g.collections = db.query(Collection)\ + .order_by(Collection.order.desc(), Collection.name.desc())\ + .all() + for collection in g.collections: + db.expunge(collection) + if not g.collections: + abort(500, "No collections setup") + g.collections_by_name = {c.name: c for c in g.collections} + g.collections_by_id = {c.id: c for c in g.collections} + g.current_collections = [] + if collection_name: + try: + for component in collection_name.split(','): + g.current_collections.append(g.collections_by_name[component]) + except KeyError: + abort(404, "Collection not found") + else: + g.current_collections = g.collections + + +def secondary_koji_url(collection): + if collection.secondary_mode: + return get_config('secondary_koji_config.weburl') + return get_config('koji_config.weburl') + + +app.jinja_env.globals.update( + # configuration variables + koschei_version=get_config('version'), + primary_koji_url=get_config('koji_config.weburl'), + secondary_koji_url=secondary_koji_url, + fedora_assets_url=frontend_config['fedora_assets_url'], + # builtin python functions + inext=next, iter=iter, min=min, max=max, + # model classes + Package=Package, + Build=Build, + ResolutionChange=ResolutionChange, +) diff --git a/koschei/frontend/filters.py b/koschei/frontend/filters.py new file mode 100644 index 00000000..7453e4b8 --- /dev/null +++ b/koschei/frontend/filters.py @@ -0,0 +1,43 @@ +# Copyright (C) 2014-2017 Red Hat, Inc. +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Author: Michael Simacek + +from datetime import datetime + +import humanize +from jinja2 import Markup + +from koschei.frontend.base import app + +app.add_template_filter(humanize.intcomma, 'intcomma') +app.add_template_filter(humanize.naturaltime, 'naturaltime') +app.add_template_filter(humanize.naturaldelta, 'naturaldelta') + + +@app.template_filter() +def percentage(val): + return format(val * 10000, '.4f') + Markup(' ‱') + + +@app.template_filter('date') +def date_filter(date): + return date.strftime("%F %T") if date else '' + + +@app.template_filter() +def epoch(dt): + return int((dt - datetime.fromtimestamp(0)).total_seconds()) diff --git a/koschei/frontend/forms.py b/koschei/frontend/forms.py index 1b89bce4..5e45cffc 100644 --- a/koschei/frontend/forms.py +++ b/koschei/frontend/forms.py @@ -22,7 +22,6 @@ import re from flask_wtf import Form - from wtforms import ( StringField, TextAreaField, IntegerField, BooleanField, ) @@ -30,8 +29,7 @@ from wtforms.widgets import HTMLString, HiddenInput from koschei.config import get_koji_config - -from koschei.frontend import flash_nak +from koschei.frontend.util import flash_nak class CheckBoxField(BooleanField): diff --git a/koschei/frontend/model_additions.py b/koschei/frontend/model_additions.py new file mode 100644 index 00000000..1137c093 --- /dev/null +++ b/koschei/frontend/model_additions.py @@ -0,0 +1,100 @@ +# Copyright (C) 2014-2016 Red Hat, Inc. +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Author: Michael Simacek +# Author: Mikolaj Izdebski + +from __future__ import print_function, absolute_import + +from flask import url_for +from jinja2 import Markup + +from koschei.frontend.base import app +from koschei.models import Package, Build, ResolutionChange + + +def icon(name, title=None): + url = url_for('static', filename='images/{}.png'.format(name)) + return Markup( + '' + .format(url=url, title=title or name) + ) + + +def package_state_icon(package_or_state): + state_string = getattr(package_or_state, 'state_string', package_or_state) + icon_name = { + 'ok': 'complete', + 'failing': 'failed', + 'unresolved': 'cross', + 'blocked': 'unknown', + 'untracked': 'unknown' + }.get(state_string, 'unknown') + return icon(icon_name, state_string) +Package.state_icon = property(package_state_icon) + + +def package_running_icon(self): + if self.has_running_build: + return icon('running') + return '' +Package.running_icon = property(package_running_icon) + + +def resolution_state_icon(resolved): + if resolved is None: + return icon('unknown') + if resolved is True: + return icon('complete') + return icon('cross') + + +def build_state_icon(build_or_state): + if build_or_state is None: + return "" + if isinstance(build_or_state, int): + state_string = Build.REV_STATE_MAP[build_or_state] + else: + state_string = getattr(build_or_state, 'state_string', build_or_state) + return icon(state_string) +Build.state_icon = property(build_state_icon) + + +def build_css_class(build): + css = '' + if build.untagged: + css += " kk-untagged-build" + if build.real: + css += " table-info" + if build.state == Build.FAILED: + css += " table-warning" + return css +Build.css_class = property(build_css_class) + + +def resolution_change_css_class(resolution_change): + if resolution_change.resolved: + return "table-success" + return "table-danger" +ResolutionChange.css_class = property(resolution_change_css_class) + + +app.jinja_env.globals.update( + # state icon functions + package_state_icon=package_state_icon, + build_state_icon=build_state_icon, + resolution_state_icon=resolution_state_icon, +) diff --git a/koschei/frontend/tabs.py b/koschei/frontend/tabs.py new file mode 100644 index 00000000..8d612851 --- /dev/null +++ b/koschei/frontend/tabs.py @@ -0,0 +1,71 @@ +# Copyright (C) 2014-2016 Red Hat, Inc. +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Author: Michael Simacek + +from __future__ import print_function, absolute_import + +from functools import wraps + +from flask import g, url_for + +from koschei.frontend.base import app + +tabs = [] + + +class Tab(object): + def __init__(self, name, order=0, requires_user=False): + self.name = name + self.order = order + self.requires_user = requires_user + self.master_endpoint = None + for i, tab in enumerate(tabs): + if tab.order > order: + tabs.insert(i, self) + break + else: + tabs.append(self) + + def __call__(self, fn): + @wraps(fn) + def decorated(*args, **kwargs): + g.current_tab = self + return fn(*args, **kwargs) + return decorated + + def master(self, fn): + self.master_endpoint = fn + return self(fn) + + @property + def url(self): + name = self.master_endpoint.__name__ + if self.requires_user: + return url_for(name, username=g.user.name) + return url_for(name) + + @staticmethod + def get_tabs(): + return [t for t in tabs if t.master_endpoint and not t.requires_user] + + @staticmethod + def get_user_tabs(): + return [t for t in tabs if t.master_endpoint and t.requires_user] + + +app.jinja_env.globals['get_tabs'] = Tab.get_tabs +app.jinja_env.globals['get_user_tabs'] = Tab.get_user_tabs diff --git a/koschei/frontend/template_functions.py b/koschei/frontend/template_functions.py new file mode 100644 index 00000000..9e304501 --- /dev/null +++ b/koschei/frontend/template_functions.py @@ -0,0 +1,94 @@ +# Copyright (C) 2014-2016 Red Hat, Inc. +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Author: Michael Simacek +# Author: Mikolaj Izdebski + +from __future__ import print_function, absolute_import + +import re + +import six.moves.urllib as urllib +from flask import request, g +from jinja2 import Markup, escape + +from koschei.config import get_config +from koschei.frontend.base import app, db +from koschei.models import AdminNotice, BuildrootProblem + + +def page_args(clear=False, **kwargs): + def proc_order(order): + new_order = [] + for item in order: + if (item.replace('-', '') + not in new_order and '-' + item not in new_order): + new_order.append(item) + return ','.join(new_order) + if 'order_by' in kwargs: + kwargs['order_by'] = proc_order(kwargs['order_by']) + # the supposedly unnecessary call to items() is needed + unfiltered = kwargs if clear else dict(request.args.items(), **kwargs) + args = {k: v for k, v in unfiltered.items() if v is not None} + encoded = urllib.parse.urlencode(args) + return '?' + encoded + + +def generate_links(package): + for link_dict in get_config('links'): + name = link_dict['name'] + url = link_dict['url'] + try: + for interp in re.findall(r'\{([^}]+)\}', url): + if not re.match(r'package\.?', interp): + raise RuntimeError("Only 'package' variable can be " + "interpolated into link url") + value = package + for part in interp.split('.')[1:]: + value = getattr(value, part) + if value is None: + raise AttributeError() # continue the outer loop + url = url.replace('{' + interp + '}', + escape(urllib.parse.quote_plus(str(value)))) + yield name, url + except AttributeError: + continue + + +def get_global_notices(): + notices = [n.content for n in + db.query(AdminNotice.content).filter_by(key="global_notice")] + for collection in g.current_collections: + if collection.latest_repo_resolved is False: + problems = db.query(BuildrootProblem)\ + .filter_by(collection_id=collection.id).all() + notices.append("Base buildroot for {} is not installable. " + "Dependency problems:
".format(collection) + + '
'.join((p.problem for p in problems))) + notices = list(map(Markup, notices)) + return notices + + +def require_login(): + return " " if g.user else ' disabled="true" ' + + +app.jinja_env.globals.update( + page_args=page_args, + generate_links=generate_links, + get_global_notices=get_global_notices, + require_login=require_login, +) diff --git a/koschei/frontend/util.py b/koschei/frontend/util.py new file mode 100644 index 00000000..8d4cf9f9 --- /dev/null +++ b/koschei/frontend/util.py @@ -0,0 +1,69 @@ +# Copyright (C) 2014-2016 Red Hat, Inc. +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Author: Michael Simacek + +from __future__ import print_function, absolute_import + +from flask import abort, flash + + +def flash_ack(message): + """Send flask flash with message about operation that was successfully + completed.""" + flash(message, 'success') + + +def flash_nak(message): + """Send flask flash with with message about operation that was not + completed due to an error.""" + flash(message, 'danger') + + +def flash_info(message): + """Send flask flash with informational message.""" + flash(message, 'info') + + +class Reversed(object): + def __init__(self, content): + self.content = content + + def desc(self): + return self.content + + def asc(self): + return self.content.desc() + + +class NullsLastOrder(Reversed): + def asc(self): + return self.content.desc().nullslast() + + +def get_order(order_map, order_spec): + orders = [] + components = order_spec.split(',') + for component in components: + if component: + if component.startswith('-'): + order = [o.desc() for o in order_map.get(component[1:], ())] + else: + order = [o.asc() for o in order_map.get(component, ())] + orders.extend(order) + if any(order is None for order in orders): + abort(400) + return components, orders diff --git a/koschei/frontend/views.py b/koschei/frontend/views.py index 558248b3..750c298a 100644 --- a/koschei/frontend/views.py +++ b/koschei/frontend/views.py @@ -19,134 +19,35 @@ from __future__ import print_function, absolute_import -import re -import six.moves.urllib as urllib -from datetime import datetime from textwrap import dedent +import six.moves.urllib as urllib from flask import abort, render_template, request, url_for, redirect, g -from jinja2 import Markup, escape from sqlalchemy import Integer from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import (joinedload, subqueryload, undefer, contains_eager, - aliased) +from sqlalchemy.orm import ( + joinedload, subqueryload, undefer, contains_eager, aliased, +) from sqlalchemy.sql import exists, func, false, true, cast, union from koschei import plugin, data -from koschei.db import RpmEVR from koschei.config import get_config -from koschei.frontend import (app, db, frontend_config, auth, session, - forms, Tab, flash_ack, flash_nak) +from koschei.db import RpmEVR +from koschei.frontend import forms, auth +from koschei.frontend.base import frontend_config, db, app, session +from koschei.frontend.model_additions import package_running_icon +from koschei.frontend.tabs import Tab +from koschei.frontend.util import flash_nak, flash_ack, Reversed, NullsLastOrder, \ + get_order from koschei.models import ( - Package, Build, PackageGroup, PackageGroupRelation, AdminNotice, - BuildrootProblem, BasePackage, GroupACL, Collection, CollectionGroup, - AppliedChange, UnappliedChange, ResolutionChange, ResourceConsumptionStats, - ScalarStats, Dependency, + Package, Build, PackageGroup, PackageGroupRelation, BasePackage, GroupACL, + CollectionGroup, AppliedChange, UnappliedChange, ResolutionChange, + ResourceConsumptionStats, ScalarStats, Dependency, ) packages_per_page = frontend_config['packages_per_page'] builds_per_page = frontend_config['builds_per_page'] -plugin.load_plugins('frontend') - - -def page_args(clear=False, **kwargs): - def proc_order(order): - new_order = [] - for item in order: - if (item.replace('-', '') - not in new_order and '-' + item not in new_order): - new_order.append(item) - return ','.join(new_order) - if 'order_by' in kwargs: - kwargs['order_by'] = proc_order(kwargs['order_by']) - # the supposedly unnecessary call to items() is needed - unfiltered = kwargs if clear else dict(request.args.items(), **kwargs) - args = {k: v for k, v in unfiltered.items() if v is not None} - encoded = urllib.parse.urlencode(args) - return '?' + encoded - - -def generate_links(package): - for link_dict in get_config('links'): - name = link_dict['name'] - url = link_dict['url'] - try: - for interp in re.findall(r'\{([^}]+)\}', url): - if not re.match(r'package\.?', interp): - raise RuntimeError("Only 'package' variable can be " - "interpolated into link url") - value = package - for part in interp.split('.')[1:]: - value = getattr(value, part) - if value is None: - raise AttributeError() # continue the outer loop - url = url.replace('{' + interp + '}', - escape(urllib.parse.quote_plus(str(value)))) - yield name, url - except AttributeError: - continue - - -@app.template_filter() -def epoch(dt): - return int((dt - datetime.fromtimestamp(0)).total_seconds()) - - -def get_global_notices(): - notices = [n.content for n in - db.query(AdminNotice.content).filter_by(key="global_notice")] - for collection in g.current_collections: - if collection.latest_repo_resolved is False: - problems = db.query(BuildrootProblem)\ - .filter_by(collection_id=collection.id).all() - notices.append("Base buildroot for {} is not installable. " - "Dependency problems:
".format(collection) + - '
'.join((p.problem for p in problems))) - notices = list(map(Markup, notices)) - return notices - - -def require_login(): - return " " if g.user else ' disabled="true" ' - - -def secondary_koji_url(collection): - if collection.secondary_mode: - return get_config('secondary_koji_config.weburl') - return get_config('koji_config.weburl') - - -class Reversed(object): - def __init__(self, content): - self.content = content - - def desc(self): - return self.content - - def asc(self): - return self.content.desc() - - -class NullsLastOrder(Reversed): - def asc(self): - return self.content.desc().nullslast() - - -def get_order(order_map, order_spec): - orders = [] - components = order_spec.split(',') - for component in components: - if component: - if component.startswith('-'): - order = [o.desc() for o in order_map.get(component[1:], ())] - else: - order = [o.asc() for o in order_map.get(component, ())] - orders.extend(order) - if any(order is None for order in orders): - abort(400) - return components, orders - def populate_package_groups(packages): base_map = {} @@ -218,139 +119,6 @@ def collection_package_view(template, query_fn=None, **template_args): **template_args) -def icon(name, title=None): - url = url_for('static', filename='images/{}.png'.format(name)) - return Markup( - '' - .format(url=url, title=title or name) - ) - - -def package_state_icon(package_or_state): - state_string = getattr(package_or_state, 'state_string', package_or_state) - icon_name = { - 'ok': 'complete', - 'failing': 'failed', - 'unresolved': 'cross', - 'blocked': 'unknown', - 'untracked': 'unknown' - }.get(state_string, 'unknown') - return icon(icon_name, state_string) -Package.state_icon = property(package_state_icon) - - -def package_running_icon(self): - if self.has_running_build: - return icon('running') - return '' -Package.running_icon = property(package_running_icon) - - -def resolution_state_icon(resolved): - if resolved is None: - return icon('unknown') - if resolved is True: - return icon('complete') - return icon('cross') - - -def build_state_icon(build_or_state): - if build_or_state is None: - return "" - if isinstance(build_or_state, int): - state_string = Build.REV_STATE_MAP[build_or_state] - else: - state_string = getattr(build_or_state, 'state_string', build_or_state) - return icon(state_string) -Build.state_icon = property(build_state_icon) - - -def build_css_class(build): - css = '' - if build.untagged: - css += " kk-untagged-build" - if build.real: - css += " table-info" - if build.state == Build.FAILED: - css += " table-warning" - return css -Build.css_class = property(build_css_class) - - -def resolution_change_css_class(resolution_change): - if resolution_change.resolved: - return "table-success" - return "table-danger" -ResolutionChange.css_class = property(resolution_change_css_class) - - -app.jinja_env.globals.update( - primary_koji_url=get_config('koji_config.weburl'), - secondary_koji_url=secondary_koji_url, - koschei_version=get_config('version'), - generate_links=generate_links, - inext=next, iter=iter, - min=min, max=max, page_args=page_args, - get_global_notices=get_global_notices, - require_login=require_login, - Package=Package, Build=Build, - ResolutionChange=ResolutionChange, - package_state_icon=package_state_icon, - build_state_icon=build_state_icon, - resolution_state_icon=resolution_state_icon, - fedora_assets_url=frontend_config['fedora_assets_url']) - - -@app.teardown_appcontext -def shutdown_session(exception=None): - db.remove() - - -@app.template_filter('date') -def date_filter(date): - return date.strftime("%F %T") if date else '' - - -@app.context_processor -def inject_times(): - return {'since': datetime.min, 'until': datetime.now()} - - -@app.context_processor -def inject_fedmenu(): - if 'fedmenu_url' in frontend_config: - return { - 'fedmenu_url': frontend_config['fedmenu_url'], - 'fedmenu_data_url': frontend_config['fedmenu_data_url'], - } - return {} - - -@app.before_request -def get_collections(): - if request.endpoint == 'static': - return - collection_name = request.args.get('collection') - g.collections = db.query(Collection)\ - .order_by(Collection.order.desc(), Collection.name.desc())\ - .all() - for collection in g.collections: - db.expunge(collection) - if not g.collections: - abort(500, "No collections setup") - g.collections_by_name = {c.name: c for c in g.collections} - g.collections_by_id = {c.id: c for c in g.collections} - g.current_collections = [] - if collection_name: - try: - for component in collection_name.split(','): - g.current_collections.append(g.collections_by_name[component]) - except KeyError: - abort(404, "Collection not found") - else: - g.current_collections = g.collections - - class UnifiedPackage(object): def __init__(self, row): self.name = row.name diff --git a/koschei/plugins/copr_plugin/frontend.py b/koschei/plugins/copr_plugin/frontend.py index 71c28605..a3921e05 100644 --- a/koschei/plugins/copr_plugin/frontend.py +++ b/koschei/plugins/copr_plugin/frontend.py @@ -19,16 +19,18 @@ from __future__ import print_function, absolute_import from flask import abort, render_template, url_for, redirect, g -from wtforms import validators, widgets, IntegerField, StringField from sqlalchemy import func from sqlalchemy.orm import joinedload, subqueryload +from wtforms import validators, widgets, IntegerField, StringField from koschei.config import get_config -from koschei.frontend import app, auth, db, Tab, flash_ack +from koschei.frontend import auth +from koschei.frontend.base import app, db from koschei.frontend.forms import StrippedStringField, EmptyForm +from koschei.frontend.tabs import Tab +from koschei.frontend.util import flash_ack from koschei.models import User, CoprRebuildRequest, CoprRebuild - app.jinja_env.globals.update( copr_frontend_url=get_config('copr.frontend_url'), copr_owner=get_config('copr.copr_owner'), diff --git a/test/api_test.py b/test/api_test.py index 32a8b883..bb387dda 100644 --- a/test/api_test.py +++ b/test/api_test.py @@ -19,8 +19,8 @@ from __future__ import absolute_import from flask import json -from koschei.models import Collection +from koschei.models import Collection from test.frontend_common import FrontendTest diff --git a/test/frontend_common.py b/test/frontend_common.py index 3fc5cac8..06c260d3 100644 --- a/test/frontend_common.py +++ b/test/frontend_common.py @@ -18,15 +18,9 @@ from functools import wraps -# pylint:disable = unused-import -import koschei.frontend.api -import koschei.frontend.views -import koschei.frontend.auth - -from koschei.frontend import app, db -from koschei.models import User, PackageGroup, AppliedChange - -from test.common import DBTest, my_vcr +from koschei.frontend import app +from koschei.frontend.base import db +from test.common import DBTest def login(client, user): diff --git a/test/web_test.py b/test/web_test.py index f8164e06..57efc740 100644 --- a/test/web_test.py +++ b/test/web_test.py @@ -18,15 +18,8 @@ import re -# pylint:disable = unused-import -import koschei.frontend.api -import koschei.frontend.views -import koschei.frontend.auth - -from koschei.frontend import app, db -from koschei.models import User, PackageGroup, AppliedChange - -from test.common import DBTest, my_vcr +from koschei.models import PackageGroup +from test.common import my_vcr from test.frontend_common import FrontendTest, authenticate, authenticate_admin