Initial commit

This commit is contained in:
Josef Skladanka 2015-06-08 22:43:53 +02:00
commit 06c495c679
44 changed files with 2185 additions and 0 deletions

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
*.pyc
*.pyo
*.swp
*__pycache__*
.ropeproject/
conf/settings.py
*.sqlite
*.sass-cache
*build/
*dist/
*.egg*
/env
*.tar.gz
*.rpm

2
MANIFEST.in Normal file
View file

@ -0,0 +1,2 @@
recursive-include testdays/templates *
recursive-include testdays/static *

87
Makefile Normal file
View file

@ -0,0 +1,87 @@
#
# Copyright 2013, 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.
#
.PHONY: test test-ci pylint pep8 docs clean virtualenv
# general variables
VENV=test_env
SRC=testdays
# Variables used for packaging
SPECFILE=$(SRC).spec
BASEARCH:=$(shell uname -i)
DIST:=$(shell rpm --eval '%{dist}')
VERSION:=$(shell rpmspec -q --queryformat="%{VERSION}\n" $(SPECFILE) | uniq)
RELEASE:=$(subst $(DIST),,$(shell rpmspec -q --queryformat="%{RELEASE}\n" $(SPECFILE) | uniq))
NVR:=$(SRC)-$(VERSION)-$(RELEASE)
GITBRANCH:=$(shell git rev-parse --abbrev-ref HEAD)
TARGETDIST:=fc20
BUILDTARGET=fedora-20-x86_64
test: $(VENV)
sh -c "TEST='true' . $(VENV)/bin/activate; py.test --cov $(SRC) testing/; deactivate"
test-ci: $(VENV)
sh -c "TEST='true' . $(VENV)/bin/activate; py.test --cov-report xml --cov $(SRC) testing/; deactivate"
pylint:
pylint -f parseable $(SRC) | tee pylint.out
pep8:
pep8 $(SRC)/*.py $(SRC)/*/*.py | tee pep8.out
ci: test-ci pylint pep8
docs:
sphinx-build -b html -d docs/_build/doctrees docs/source docs/_build/html
clean:
rm -rf dist
rm -rf testdays.egg-info
rm -rf build
rm -f pep8.out
rm -f pylint.out
archive: $(SRC)-$(VERSION).tar.gz
$(SRC)-$(VERSION).tar.gz:
git archive $(GITBRANCH) --prefix=$(SRC)-$(VERSION)/ | gzip -c9 > $@
mocksrpm: archive
mock -r $(BUILDTARGET) --buildsrpm --spec $(SPECFILE) --sources .
cp /var/lib/mock/$(BUILDTARGET)/result/$(NVR).$(TARGETDIST).src.rpm .
mockbuild: mocksrpm
mock -r $(BUILDTARGET) --no-clean --rebuild $(NVR).$(TARGETDIST).src.rpm
cp /var/lib/mock/$(BUILDTARGET)/result/$(NVR).$(TARGETDIST).noarch.rpm .
#kojibuild: mocksrpm
# koji build --scratch dist-6E-epel-testing-candidate $(NVR).$(TARGETDIST).src.rpm
nvr:
@echo $(NVR)
cleanvenv:
rm -rf $(VENV)
virtualenv: $(VENV)
$(VENV):
virtualenv --distribute --system-site-packages $(VENV)
sh -c ". $(VENV)/bin/activate; pip install --force-reinstall -r requirements.txt; deactivate"

1
README.md Normal file
View file

@ -0,0 +1 @@
testdays

74
alembic.ini Normal file
View file

@ -0,0 +1,74 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# max length of characters to apply to the
# "slug" field
#truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; this defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat alembic/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[alembic-packaged]
# path to migration scripts on a packaged install
script_location = /usr/share/testdays/alembic
sqlalchemy.url = driver://user:pass@localhost/dbname
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

79
alembic/env.py Normal file
View file

@ -0,0 +1,79 @@
from __future__ import with_statement
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
# add '.' to the pythonpath to support migration inside development env
import sys
sys.path.append('.')
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
from testdays import db
target_metadata = db.metadata
#target_metadata = None
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
alembic_config = config.get_section(config.config_ini_section)
from testdays import app
alembic_config['sqlalchemy.url'] = app.config['SQLALCHEMY_DATABASE_URI']
engine = engine_from_config(
alembic_config,
prefix='sqlalchemy.',
poolclass=pool.NullPool)
connection = engine.connect()
context.configure(
connection=connection,
target_metadata=target_metadata
)
try:
with context.begin_transaction():
context.run_migrations()
finally:
connection.close()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
alembic/script.py.mako Normal file
View file

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,28 @@
"""Initial revision
Revision ID: 15f5eeb9f635
Revises:
Create Date: 2015-04-29 13:43:16.481727
"""
# revision identifiers, used by Alembic.
revision = '15f5eeb9f635'
down_revision = None
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
pass
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
pass
### end Alembic commands ###

View file

@ -0,0 +1,34 @@
"""Add the User table
Revision ID: 293b84dc3f9e
Revises: 15f5eeb9f635
Create Date: 2015-04-29 13:44:16.481727
"""
# revision identifiers, used by Alembic.
revision = '293b84dc3f9e'
down_revision = '15f5eeb9f635'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=80), nullable=True),
sa.Column('pw_hash', sa.String(length=120), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username')
)
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_table('user')
### end Alembic commands ###

View file

@ -0,0 +1,59 @@
"""Added Testday-related tables
Revision ID: 3e508b27e5b6
Revises: 293b84dc3f9e
Create Date: 2015-05-07 11:04:01.305712
"""
# revision identifiers, used by Alembic.
revision = '3e508b27e5b6'
down_revision = '293b84dc3f9e'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('event',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.Text(), nullable=True),
sa.Column('metadata_url', sa.Text(), nullable=True),
sa.Column('testday_url', sa.Text(), nullable=True),
sa.Column('resultsdb_job_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('category',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('event_id', sa.Integer(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
sa.Column('col_1_name', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['event_id'], ['event.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('category_fk_event_id', 'category', ['event_id'], unique=False)
op.create_table('testcase',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
sa.Column('type', sa.Enum('tc', 'txt', name='testcase_type'), nullable=True),
sa.Column('url', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['category_id'], ['category.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('testcase_fk_category_id', 'testcase', ['category_id'], unique=False)
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_index('testcase_fk_category_id', table_name='testcase')
op.drop_table('testcase')
op.drop_index('category_fk_event_id', table_name='category')
op.drop_table('category')
op.drop_table('event')
### end Alembic commands ###

10
conf/settings.py.example Normal file
View file

@ -0,0 +1,10 @@
# you can use this as a template
SECRET_KEY = 'not-really-a-secret'
SQLALCHEMY_DATABASE_URI = 'postgresql+psycopg2://dbuser:dbpassword@dbhost:dbport/dbname'
SHOW_DB_URI = False
PRODUCTION = True
FILE_LOGGING = False
SYSLOG_LOGGING = False
STREAM_LOGGING = True
LOGFILE = '/var/log/testdays/testdays.log'

33
conf/testdays.conf Normal file
View file

@ -0,0 +1,33 @@
WSGIDaemonProcess testdays user=apache group=apache threads=5
WSGIScriptAlias /testdays /usr/share/testdays/testdays.wsgi
WSGISocketPrefix run/wsgi
# this isn't the best way to force SSL but it works for now
#RewriteEngine On
#RewriteCond %{HTTPS} !=on
#RewriteRule ^/testdays/admin/?(.*) https://%{SERVER_NAME}/$1 [R,L]
<Directory /usr/share/testdays>
WSGIProcessGroup testdays
WSGIApplicationGroup %{GLOBAL}
WSGIScriptReloading On
<IfModule mod_authz_core.c>
# Apache 2.4
<RequireAny>
Require all granted
</RequireAny>
</IfModule>
<IfModule !mod_auth_core.c>
Order allow,deny
Allow from all
</IfModule>
</Directory>
#Alias /testdays/static /var/www/testdays/testdays/static
#<Directory /var/www/testdays/testdays/static>
#Order allow,deny
#Allow from all
#</Directory>

15
conf/testdays.wsgi Normal file
View file

@ -0,0 +1,15 @@
# This is required for running on EL6
import __main__
__main__.__requires__ = ['SQLAlchemy >= 0.7', 'Flask >= 0.9', 'jinja2 >= 2.6']
import pkg_resources
# if you're running the app from a virtualenv, uncomment these lines
#activate_this = '/var/www/testdays/env/bin/activate_this.py'
#execfile(activate_this, dict(__file__=activate_this))
#import sys
#sys.path.insert(0,"/var/www/testdays/testdays/")
import os
os.environ['TESTDAYS_CONFIG'] = '/etc/testdays/settings.py'
from testdays import app as application

8
init_db.sh Normal file
View file

@ -0,0 +1,8 @@
#!/usr/bin/bash
# this is a simple script to aid in the setup of a new db for F18
# init db
python run_cli.py init_db
# insert mock data
python run_cli.py mock_data

7
requirements.txt Normal file
View file

@ -0,0 +1,7 @@
Flask>=0.9
Flask-SQLAlchemy>=0.16
SQLAlchemy>= 0.8.7
WTForms>2.0
Flask-WTF>=0.10.0
Flask-Login>=0.2.2
python-fedora

29
run_cli.py Normal file
View file

@ -0,0 +1,29 @@
#!/usr/bin/python
#
# Copyright 2014, 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.
#
# Authors:
# Josef Skladanka <jskladan@redhat.com>
import sys
from testdays import cli
if __name__ == '__main__':
exit = cli.main()
if exit:
sys.exit(exit)

33
runapp.py Normal file
View file

@ -0,0 +1,33 @@
#!/usr/bin/python
#
# runapp.py - script to facilitate running the testdays app from the CLI
#
# Copyright 2014, 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.
#
# Authors:
# Josef Skladanka <jskladan@redhat.com>
import os
import testdays
if __name__ == '__main__':
testdays.app.run(
host = testdays.app.config['RUN_HOST'],
port = testdays.app.config['RUN_PORT'],
debug = testdays.app.config['DEBUG'],
)

32
setup.py Normal file
View file

@ -0,0 +1,32 @@
#!/usr/bin/python
from setuptools import setup
import codecs
import re
import os
here = os.path.abspath(os.path.dirname(__file__))
def read(*parts):
return codecs.open(os.path.join(here, *parts), 'r').read()
def find_version(*file_paths):
version_file = read(*file_paths)
version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]",
version_file, re.M)
if version_match:
return version_match.group(1)
raise RuntimeError("Unable to find version string.")
setup(name='testdays',
version=find_version('testdays', '__init__.py'),
description='',
author='Josef Skladanka',
author_email='jskladan@redhat.com',
license='GPLv2+',
packages=['testdays', 'testdays.controllers', 'testdays.models'],
package_dir={'testdays':'testdays'},
entry_points=dict(console_scripts=['testdays=testdays.cli:main']),
include_package_data=True,
)

57
testdays.spec Normal file
View file

@ -0,0 +1,57 @@
%if ! (0%{?fedora} > 12 || 0%{?rhel} > 5)
%{!?python_sitelib: %global python_sitelib %(%{__python} -c "from distutils.sysconfig import get_python_lib; print(get_python_lib())")}
%{!?python_sitearch: %global python_sitearch %(%{__python} -c "from distutils.sysconfig import get_python_lib; print(get_python_lib(1))")}
%endif
Name: testdays
Version: 0.0.1
Release: 1%{?dist}
Summary: I was lazy enough not-to change summary
License: GPLv2+
URL: https://bitbucket.org/fedoraqa/testdays
Source0: https://qadevel.cloud.fedoraproject.org/releases/%{name}/%{name}-%{version}.tar.gz
BuildArch: noarch
Requires: python-flask
Requires: python-flask-sqlalchemy
Requires: python-flask-wtf
Requires: python-flask-login
Requires: python-alembic
BuildRequires: python2-devel python-setuptools
%description
I was so lazy, that I did not change the description.
%prep
%setup -q
%build
%{__python2} setup.py build
%install
%{__python2} setup.py install --skip-build --root %{buildroot}
# apache and wsgi settings
mkdir -p %{buildroot}%{_datadir}/testdays/conf
cp conf/testdays.conf %{buildroot}%{_datadir}/testdays/conf/.
cp conf/testdays.wsgi %{buildroot}%{_datadir}/testdays/.
mkdir -p %{buildroot}%{_sysconfdir}/testdays
install conf/settings.py.example %{buildroot}%{_sysconfdir}/testdays/settings.py.example
%files
%doc README.md conf/*
%{python_sitelib}/testdays
%{python_sitelib}/*.egg-info
%attr(755,root,root) %{_bindir}/testdays
%dir %{_sysconfdir}/testdays
%{_sysconfdir}/testdays/*
%dir %{_datadir}/testdays
%{_datadir}/testdays/*
%changelog
* Thu Feb 6 2014 Josef Skladanka <jskladan@redhat.com> - 0.0.1
- initial packaging

118
testdays/__init__.py Normal file
View file

@ -0,0 +1,118 @@
# Copyright 2014, 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.
#
# Authors:
# Josef Skladanka <jskladan@redhat.com>
from flask import Flask, render_template
from flask.ext.login import LoginManager
from flask.ext.sqlalchemy import SQLAlchemy
import logging
import os
# the version as used in setup.py
__version__ = "0.0.1"
# Flask App
app = Flask(__name__)
app.secret_key = 'not-really-a-secret'
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
# Load default config, then override that with a config file
if os.getenv('PROD') == 'true':
default_config_obj = 'testdays.config.ProductionConfig'
default_config_file = '/etc/testdays/settings.py'
elif os.getenv('TEST') == 'true':
default_config_obj = 'testdays.config.TestingConfig'
default_config_file = os.getcwd() + '/conf/settings.py'
else:
default_config_obj = 'testdays.config.DevelopmentConfig'
default_config_file = os.getcwd() + '/conf/settings.py'
app.config.from_object(default_config_obj)
config_file = os.environ.get('TESTDAYS_CONFIG', default_config_file)
if os.path.exists(config_file):
app.config.from_pyfile(config_file)
if app.config['PRODUCTION']:
if app.secret_key == 'not-really-a-secret':
raise Warning("You need to change the app.secret_key value for production")
# setup logging
fmt = '[%(filename)s:%(lineno)d] ' if app.debug else '%(module)-12s '
fmt += '%(asctime)s %(levelname)-7s %(message)s'
datefmt = '%Y-%m-%d %H:%M:%S'
loglevel = logging.DEBUG if app.debug else logging.INFO
formatter = logging.Formatter(fmt=fmt, datefmt=datefmt)
def setup_logging():
root_logger = logging.getLogger('')
root_logger.setLevel(logging.DEBUG)
if app.config['STREAM_LOGGING']:
print "doing stream logging"
stream_handler = logging.StreamHandler()
stream_handler.setLevel(loglevel)
stream_handler.setFormatter(formatter)
root_logger.addHandler(stream_handler)
app.logger.addHandler(stream_handler)
if app.config['SYSLOG_LOGGING']:
print "doing syslog logging"
syslog_handler = logging.handlers.SysLogHandler(address='/dev/log',
facility=logging.handlers.SysLogHandler.LOG_LOCAL4)
syslog_handler.setLevel(loglevel)
syslog_handler.setFormatter(formatter)
root_logger.addHandler(syslog_handler)
app.logger.addHandler(syslog_handler)
if app.config['FILE_LOGGING'] and app.config['LOGFILE']:
print "doing file logging to %s" % app.config['LOGFILE']
file_handler = logging.handlers.RotatingFileHandler(app.config['LOGFILE'], maxBytes=500000, backupCount=5)
file_handler.setLevel(loglevel)
file_handler.setFormatter(formatter)
root_logger.addHandler(file_handler)
app.logger.addHandler(file_handler)
setup_logging()
if app.config['SHOW_DB_URI']:
app.logger.debug('using DBURI: %s' % app.config['SQLALCHEMY_DATABASE_URI'])
# database
db = SQLAlchemy(app)
# setup login manager
login_manager = LoginManager()
login_manager.setup_app(app)
login_manager.login_view = 'login_page.login'
# register blueprints
from testdays.controllers.main import main
app.register_blueprint(main)
from testdays.controllers.login_page import login_page
app.register_blueprint(login_page)
from testdays.controllers.admin import admin
app.register_blueprint(admin)

133
testdays/cli.py Normal file
View file

@ -0,0 +1,133 @@
# Copyright 2014, 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.
#
# Authors:
# Josef Skladanka <jskladan@redhat.com>
# This is required for running on EL6
import __main__
__main__.__requires__ = ['SQLAlchemy >= 0.7', 'Flask >= 0.9', 'jinja2 >= 2.6']
import pkg_resources
import os
import sys
from optparse import OptionParser
from alembic.config import Config
from alembic import command as al_command
from alembic.migration import MigrationContext
from testdays import db
from testdays.models.user import User
def get_alembic_config():
# the location of the alembic ini file and alembic scripts changes when
# installed via package
if os.path.exists("./alembic.ini"):
alembic_cfg = Config("./alembic.ini")
else:
alembic_cfg = Config("/usr/share/resultsdb/alembic.ini",
ini_section='alembic-packaged')
return alembic_cfg
def upgrade_db(*args):
alembic_cfg = get_alembic_config()
context = MigrationContext.configure(db.engine.connect())
current_rev = context.get_current_revision()
print "Upgrading Database to `head` from `%s`" % current_rev
al_command.upgrade(alembic_cfg, "head")
def init_alembic(*args):
alembic_cfg = get_alembic_config()
# check to see if the db has already been initialized by checking for an
# alembic revision
context = MigrationContext.configure(db.engine.connect())
current_rev = context.get_current_revision()
if not current_rev:
print "Initializing alembic"
print " - Setting the version to the first revision"
al_command.stamp(alembic_cfg, "15f5eeb9f635")
else:
print "Alembic already initialized"
def initialize_db(destructive):
alembic_cfg = get_alembic_config()
print "Initializing database"
if destructive:
print " - Dropping all tables"
db.drop_all()
print " - Creating tables"
db.create_all()
print " - Stamping alembic's current version to 'head'"
al_command.stamp(alembic_cfg, "head")
init_alembic()
upgrade_db()
def mock_data(destructive):
print "Populating tables with mock-data"
if destructive or not db.session.query(User).count():
print " - User"
data_users = [('admin', 'admin'), ('user', 'user')]
for d in data_users:
u = User(*d)
db.session.add(u)
db.session.commit()
else:
print " - skipped User"
def main():
possible_commands = ['init_db', 'mock_data', 'upgrade_db', 'init_alembic']
usage = 'usage: [DEV=true] %prog ' + "(%s)" % ' | '.join(possible_commands)
parser = OptionParser(usage=usage)
parser.add_option("-d", "--destructive",
action="store_true", dest="destructive", default=False,
help="Drop tables in `init_db`; Store data in `mock_data` "
"even if the tables are not empty")
(options, args) = parser.parse_args()
if len(args) != 1 or args[0] not in possible_commands:
print usage
print
print 'Please use one of the following commands: %s' % str(possible_commands)
sys.exit(1)
command = {
'init_db': initialize_db,
'mock_data': mock_data,
'upgrade_db': upgrade_db,
'init_alembic': init_alembic,
}[args[0]]
if not options.destructive:
print "Proceeding with non-destructive init. To perform destructive "\
"steps use -d option."
command(options.destructive)
if __name__ == '__main__':
main()

55
testdays/config.py Normal file
View file

@ -0,0 +1,55 @@
# Copyright 2014, 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.
#
# Authors:
# Josef Skladanka <jskladan@redhat.com>
class Config(object):
DEBUG = True
SQLALCHEMY_DATABASE_URI = 'sqlite://'
LOGFILE = '/var/log/testdays/testdays.log'
FILE_LOGGING = False
SYSLOG_LOGGING = False
STREAM_LOGGING = True
RUN_HOST = None
RUN_PORT = None
PRODUCTION = False
TESTING = False
SHOW_DB_URI = False
RESULTSDB_URL = 'http://taskotron-local/resultsdb_api/api/v1.0/'
class ProductionConfig(Config):
DEBUG = False
PRODUCTION = True
class DevelopmentConfig(Config):
TRAP_BAD_REQUEST_ERRORS = True
SQLALCHEMY_DATABASE_URI = 'sqlite:////tmp/testdays_db.sqlite'
RUN_HOST = '0.0.0.0'
RUN_PORT = 5000
class TestingConfig(Config):
TRAP_BAD_REQUEST_ERRORS = True
TESTING = True

View file

View file

@ -0,0 +1,210 @@
# Copyright 2014, 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.
#
# Authors:
# Josef Skladanka <jskladan@redhat.com>
import re
from flask import Blueprint, render_template, flash, url_for, redirect, make_response
from flask.ext.login import login_required
from sqlalchemy.orm.exc import NoResultFound
from resultsdb_api import ResultsDBapi
from testdays import app, db
from testdays.controllers.main import preparse_results
import testdays.lib.wiki as wiki
from testdays.lib.forms import AddEventForm
from testdays.models.testday import Event, Category, Testcase, TESTCASE_TYPES
admin = Blueprint('admin', __name__)
RDB_API = None
@app.before_first_request
def before_first_request():
global RDB_API
RDB_API = ResultsDBapi(app.config['RESULTSDB_URL'])
@admin.route('/admin')
@admin.route('/admin/')
#@login_required
def admin_index():
return render_template('admin/index.html')
def event_from_metadata(url):
# grab the respective section from wikipage
data = wiki.get_page_section(url, "TestdayApp Metadata")
# matches text from lines enclosed in '=' characters
re_groupname = re.compile("^=+\s*(.*?)\s*=*$")
td = {"name":"", "url":"", "categories": []}
category = {"name":"", "testcases": []}
testcase = {"name":"", "url":"", "type":""}
# splits text on newlines, strips whitespaces and filters relevant lines
# the first line is deemed irrelevant, as it is just the name of the section
data = [line.strip() for line in data.split('\n')[1:]]
data = [line for line in data if line.startswith("*") or line.startswith("=")]
# Testday Name & URL
# the first line must contain testday's name and URL in the '*name;url' format
name_url = data.pop(0)
if not name_url.startswith("*"):
return (False, "Missing Testday name & URL")
try:
# remove * at the beginning, split by ';' in two parts, and strip whitespaces
td_name, td_url = [part.strip() for part in name_url[1:].split(';', 2)]
except ValueError: # split failed
return (False, "Testday name or URL missing")
# sanity check
if len(td_name) < 5:
return (False, "Testday name must be longer than 5 characters")
if not td_url.startswith("http"):
return (False, "Testday URL must start with 'http'")
if td_url.startswith("https"):
td_url = td_url.replace("https", "http", 1)
event = Event(td_name, url, td_url)
# Testcase Sections
category = None
for line in data:
# Create a new category, when we encounter category metadata
if line.startswith("="):
name = re_groupname.findall(line)[0]
if not name:
return (False, "Category name must be at least 1 character long at line %r" % line)
category = Category(name, event)
continue
# skip until we get a category
if category is None:
continue
# skip until we get a testcase
if not line.startswith("*"):
continue
# Parse the testcase metadata
# remove * at the beginning, split by ';' and strip whitespaces
l = [part.strip() for part in line[1:].split(';')]
# append default testcase result type (pass/fail/info)
l.append('tc')
if len(l) < 3:
return (False, "Testcase name or url missing on line %r" % line)
testcase = Testcase(l[0], l[1], l[2].lower(), category)
if len(testcase.name) < 1:
return (False, "Testcase name must be at least 1 character long at line %r" % line)
if not testcase.url.startswith('http'):
return (False, "Testcase url does not start with 'http' on line %r" % line)
# convert unknown types to the default "testcase" type
if testcase.type not in TESTCASE_TYPES:
testcase.type = 'tc'
# change https to http
testcase.url = testcase.url.replace("https", "http", 1)
return(True, event)
@admin.route('/admin/add_event', methods=['GET', 'POST'])
def add_event():
form_ = AddEventForm()
if form_.validate_on_submit():
was_ok, data = event_from_metadata(form_.metadata_url.data)
if was_ok:
event = data
try:
old_event = db.session.query(Event).filter(Event.metadata_url == event.metadata_url).one()
# Event with this metadata url not found -> create new event
except NoResultFound:
db.session.add(event)
db.session.commit()
db.session.add(event)
# start resultsdb job
rdb_job = RDB_API.create_job(
url_for('main.show_event', event_id=event.id),
name="Testday - %s" % event.testday_url.split('/')[-1],
)
RDB_API.update_job(rdb_job['id'], status='RUNNING')
event.resultsdb_job_id = rdb_job['id']
db.session.add(event)
db.session.commit()
flash('Event was successfully created')
# Event with this metadata url found -> update
else:
# Remove old categories and testcases
for category in old_event.categories:
for testcase in category.testcases:
db.session.delete(testcase)
db.session.delete(category)
event.id = old_event.id
event.resultsdb_job_id = old_event.resultsdb_job_id
db.session.merge(event)
db.session.commit()
flash('Event was successfully updated')
return redirect(url_for('main.show_event', event_id = event.id))
# an error was encountered, flash the error and show the add_event form
flash(data, 'error')
return render_template('admin/add_event.html',
form = form_,
form_action = url_for(".add_event"),
header = u"Add/Edit Event",
)
@admin.route('/admin/export_event', methods=['GET', 'POST'])
def export_event():
form_ = AddEventForm()
if form_.validate_on_submit():
try:
event = db.session.query(Event).filter(Event.metadata_url == form_.metadata_url.data).one()
except NoResultFound:
flash('Event not found', 'error')
results = preparse_results(event)
output = render_template('admin/export_event.txt', event=event, results=results)
response = make_response(output)
response.headers["Content-Type"] = "text/plain; charset=utf-8"
return response
return render_template('admin/add_event.html',
form = form_,
form_action = url_for(".export_event"),
header = u"Export Event",
)

View file

@ -0,0 +1,80 @@
# Copyright 2014, 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.
#
# Authors:
# Josef Skladanka <jskladan@redhat.com>
from flask import Blueprint, render_template, redirect, flash, url_for, request
from flask.ext.wtf import Form
from wtforms import TextField, PasswordField, HiddenField, RadioField
from wtforms.validators import Required
from flask.ext.login import login_user, logout_user, login_required, current_user, AnonymousUserMixin
from testdays import app, login_manager
from testdays.models.user import User
login_page = Blueprint('login_page', __name__)
class LoginForm(Form):
username = TextField(u'Username', validators = [Required()])
password = PasswordField(u'Password', validators = [Required()])
next_page = HiddenField()
# handle login stuff
@login_manager.user_loader
def load_user(userid):
app.logger.debug("getting info for user %s" % str(userid))
user = User.query.get(userid)
if user:
return user
else:
return AnonymousUserMixin
@login_page.route('/login', methods=['GET', 'POST'])
def login():
login_form = LoginForm()
if login_form.validate_on_submit():
user = User.query.filter_by(username = login_form.username.data).first()
if user and user.check_password(login_form.password.data):
login_user(user)
app.logger.info('Successful login for user %s' % login_form.username.data)
flash('Logged In Successfully!')
return redirect(login_form.next_page.data)
else:
app.logger.info('FAILED login for user %s' % login_form.username.data)
flash('Login Failed! Please Try again!')
login_form.next_page.data = request.args.get('next') or url_for('main.index')
return render_template('login.html', form = login_form)
@login_page.route('/logout')
@login_required
def logout():
app.logger.info('logout for user %s' % current_user.username)
logout_user()
flash('Logged Out Successfully!')
return redirect(url_for('main.index'))

View file

@ -0,0 +1,353 @@
# Copyright 2014, 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.
#
# Authors:
# Josef Skladanka <jskladan@redhat.com>
import re
import json
from flask import Blueprint, render_template, redirect, url_for, flash, session
import resultsdb_api
from testdays import app, db
from testdays.models.testday import Event, Category, Testcase
from testdays.lib.forms import EnterResultForm
main = Blueprint('main', __name__)
RDB_API = None
TESTCASES = []
RE_TESTCASE_NAME_NORMALIZE = re.compile(r'[^a-zA-Z0-9_]')
@app.before_first_request
def before_first_request():
global RDB_API
RDB_API = resultsdb_api.ResultsDBapi(app.config['RESULTSDB_URL'])
@main.route('/')
@main.route('/index')
def index():
return redirect(url_for('.events'))
@main.route('/events')
def events():
events = db.session.query(Event).order_by(Event.created_at)
return render_template('list_events.html', events=events)
# TODO: use Memcached instead of SimpleCache
from werkzeug.contrib.cache import SimpleCache
CACHE = SimpleCache()
CACHE_TIMEOUT = 10
def preparse_results(event):
global CACHE
cached = CACHE.get("event_%s_matrix" % event.id)
if cached is not None:
return cached
results = RDB_API.get_job(event.resultsdb_job_id)['results']
# results is a list of dicts like this:
# {
# "href": "http://taskotron-local/resultsdb_api/api/v1.0/results/1510407",
# "id": 1510407,
# "job_url": "http://taskotron-local/resultsdb_api/api/v1.0/jobs/115516",
# "log_url": null,
# "outcome": "PASSED",
# "result_data": {
# "bugs": [
# ""
# ],
# "col_1": [
# "Fedora 19"
# ],
# "result": [
# "cats!"
# ],
# "username": [
# "jskladan"
# ]
# },
# "submit_time": "2015-05-14T09:20:36.392351",
# "summary": "Comment number 4",
# "testcase": {
# "href": "http://taskotron-local/resultsdb_api/api/v1.0/testcases/testdays_QA_Testcase_Power_Management_tuned_off_idle",
# "name": "testdays_QA_Testcase_Power_Management_tuned_off_idle",
# "url": "http://fedoraproject.org/wiki/QA:Testcase_Power_Management_tuned_off_idle"
# }
# }
# The following code will rearrange the results into structure like this one:
#
# {$username: {$col_1: {$tcurl: [{'bugs': $bugs.split(';'),
# 'comment': $summary,
# 'result': $result,
# }]}}}
#
# Where $username, $col_1, and $result are from ['result_data'],
# $tcurl is the testcase url from ['testcase']['url']
by_user = {}
for result in results:
data = result['result_data']
username = data['username'][0]
col_1 = data['col_1'][0]
tcurl = result['testcase']['url']
outcome = data['result'][0]
bugs = []
if data['bugs'][0]:
bugs = data['bugs'][0].split(';')
comment = result['summary']
by_user.setdefault(username, {})
by_user[username].setdefault(col_1, {})
by_user[username][col_1].setdefault(tcurl, [])
by_user[username][col_1][tcurl].append(
dict(
result = outcome,
bugs = bugs,
comment = comment
)
)
# At this point, by_user contains something like this:
# {'usr1': {'moofoo': {'http://test2': [{'bugs': [],
# 'comment': '',
# 'result': 'INFO',
# }]}},
# 'usr2': {(foobar': {'http://test1': [{'bugs': [],
# 'comment': None,
# 'result': 'FAILED',
# }],
# 'http://test2': [{'bugs': ['12345',
# '6431'],
# 'comment': 'Does not work!',
# 'result': 'PASSED',
# }]}}}
# We'll prepare the data even further - the event's
# testcases are split into categories. We'll prepare the by-category
# matrix, se the template to show the results can stay fairly simple
# The result of the transformation is:
# {1: [{
# 'username': u'jskladan',
# 'profile': u'Ubuntu 4.1',
# 'results': [[(u'FAILED', 1), (u'PASSED', 2)], []],
# 'comments': [([u'123', u'456'], u'Comment number 2'),
# ([], u'Comment number 1')],
# },
# {
# 'username': u'jskladan',
# 'profile': u'Fedora 19',
# 'results': [[(u'INFO', False), (u'PASSED', 1)], [(u'cats!', 2)]],
# 'comments': [([], u'Comment number 3'), ([], u'Comment number 4')],
# }],
# 2: []}
#
# $results represents the results per testcase - each sublist of the top list
# 'maps' (positionaly) to the Testcase in the respective Category. It can
# contain multiple tuples, each representing one result (one user with one
# profile can submit multiple results per Testcase). The first item in the
# tuple is the actual result. The second item is either False or Int.
# False means that the result has no bugs/comment associated with it.
# Int is the index+1 of the comment in the $comments field. This way
# it is very simple to create link-references from result to comment/bugs
#
# $comments is a list of tuples, each tuple represents bugs/comment for the
# respective result (see above).
# First item in the tuple is a list of BUG numbers
# Second item is the comment itself.
matrix = {}
# We want to have the users sorted alphabetically in the table
users = by_user.keys()
users.sort()
# Create per-category pre-parsed lines of results
for category in event.categories:
matrix[category.id] = []
relevant_testcases = set([tc.url for tc in category.testcases])
# Create a line-of-results per each user's profile
for user in users:
# Also, sort by the profile-info alphabetically
user_profiles = by_user[user].keys()
user_profiles.sort()
for profile in user_profiles:
data = by_user[user][profile]
# Check whether this user-profile set any results
# for testcases from this category
if not relevant_testcases.intersection(data.keys()):
continue
# Prepare the per-line data
# see the explanation of the structure above
line = dict(
username=user,
profile=profile,
results=[],
comments=[])
comment_id = 0
for testcase in category.testcases:
# Append new results-container for the respective
# testcase.
results_by_tc = []
line['results'].append(results_by_tc)
# If the user-profile did not fill any result for this
# specific testcase, go to the next one.
# The "no results filled" state is represented by the empty
# list.
if testcase.url not in data:
continue
# Go through all the details of the results for this
# user-profile-testcase combination
for detail in data[testcase.url]:
has_comment = bool(detail['bugs'] or detail['comment'])
# If there are bugs and/or comments for the result,
# add them to the line container
if has_comment:
comment_id += 1
line['comments'].append((detail['bugs'], detail['comment']))
has_comment = comment_id
results_by_tc.append((detail['result'], has_comment))
matrix[category.id].append(line)
CACHE.set("event_%s_matrix" % event.id, matrix, CACHE_TIMEOUT)
return matrix
@main.route('/events/<int:event_id>')
def show_event(event_id):
event = db.session.query(Event).filter(Event.id == event_id).one()
results = preparse_results(event)
prof = json.loads(session.get('profile', '{}'))
username = prof.get('username')
col_1 = prof.get('col_1')
filled = prof.get('filled', {}).get(username, {}).get(col_1, [])
profile = dict(username=username, col_1=col_1, filled=filled)
return render_template('show_event.html', event=event, results=results,
profile=profile)
def testcase_to_name(testcase):
prefix = "testdays"
name = testcase.url.split('/')[-1]
name = RE_TESTCASE_NAME_NORMALIZE.sub('_', name)
return "%s_%s" % (prefix, name)
def ensure_testcase(testcase):
global TESTCASES
name = testcase_to_name(testcase)
if name not in TESTCASES:
try:
RDB_API.get_testcase(name)
except resultsdb_api.ResultsDBapiException:
try:
RDB_API.create_testcase(name, testcase.url)
except resultsdb_api.ResultsDBapiException:
raise
TESTCASES.append(name)
return name
def update_profile_cookies(username, col_1, profile=None):
if not profile:
profile = json.loads(session.get('profile', '{}'))
profile['username'] = username
profile['col_1'] = col_1
filled = profile.setdefault('filled', {})
filled.setdefault(username, {})
filled[username].setdefault(col_1, [])
session['profile'] = json.dumps(profile)
return profile
def update_filled_cookies(username, col_1, testcase_name, profile=None):
profile = update_profile_cookies(username, col_1, profile)
profile['filled'][username][col_1].append(testcase_name)
session['profile'] = json.dumps(profile)
return profile
@main.route('/events/<int:event_id>/enter_result/<int:tc_id>', methods=['GET', 'POST'])
def enter_result(event_id, tc_id):
testcase = db.session.query(Testcase).filter(Testcase.id == tc_id).one()
form_ = EnterResultForm(testcase)
session_profile = json.loads(session.get('profile', '{}'))
if form_.validate_on_submit():
outcome = form_.result.data
if testcase.type == 'txt':
outcome = 'PASSED'
testcase_name = ensure_testcase(testcase)
result_dict = dict(
job_id=testcase.category.event.resultsdb_job_id,
testcase_name=testcase_name,
outcome=outcome,
result=form_.result.data,
col_1=form_.col_1.data,
username=form_.username.data,
bugs=form_.bugs.data,
summary=form_.comment.data,
)
try:
r = RDB_API.create_result(**result_dict)
flash("Result Saved")
update_filled_cookies(form_.username.data, form_.col_1.data, testcase.name, session_profile)
return redirect(url_for('.show_event', event_id=event_id))
except resultsdb_api.ResultsDBapiException as e:
update_profile_cookies(form_.username.data, form_.col_1.data, session_profile)
flash("There was an error when saving the result (%s)" % e.message, 'error')
if not form_.errors:
if not form_.username.data:
form_.username.data = session_profile.get('username', '')
if not form_.col_1.data:
form_.col_1.data = session_profile.get('col_1', '')
return render_template('enter_result.html',
form = form_,
form_action = url_for('.enter_result', event_id=event_id, tc_id=tc_id),
header = "Enter result",
)

0
testdays/lib/__init__.py Normal file
View file

69
testdays/lib/forms.py Normal file
View file

@ -0,0 +1,69 @@
from flask.ext.wtf import Form
from wtforms.fields import TextField, SelectField, TextAreaField
from wtforms.validators import URL, Required, AnyOf, Regexp
class AddEventForm(Form):
metadata_url = TextField(u'Metadata URL', validators = [URL()])
class EnterResultFormTC(Form):
username = TextField(u'Username', validators = [Required()])
col_1 = TextField(u'Profile')
result = SelectField(
u'Result',
choices = [
('', '-- Select Result --'),
('PASSED', 'PASSED'),
('FAILED', 'FAILED'),
('INFO', 'INFO')
],
validators = [
AnyOf(('PASSED', 'FAILED', 'INFO'))
],
)
bugs = TextField(
u'Bugs',
validators = [
Regexp(r"^([0-9]+;?)*$",
message ="Please enter bug numbers divided by semicolons. i.e. '752855;25532'",
)
],
#help_text = "Bug numbers divided by semicolons, e.g. '752855;25532'"
)
comment = TextAreaField(u'Comment')
def __init__(self, col_1_name=None, *args, **kwargs):
super(EnterResultFormTC, self).__init__(*args, **kwargs)
if col_1_name is not None:
self.col_1.label.text = col_1_name
class EnterResultFormTXT(Form):
username = TextField(u'Username', validators = [Required()])
col_1 = TextField(u'Profile')
result = TextField(u'Result', validators = [Required()])
bugs = TextField(
u'Bugs',
validators = [
Regexp(r"^([0-9]+;?)*$",
message ="Please enter bug numbers divided by semicolons. i.e. '752855;25532'",
)
],
#help_text = "Bug numbers divided by semicolons, e.g. '752855;25532'"
)
comment = TextAreaField(u'Comment')
def __init__(self, col_1_name=None, *args, **kwargs):
super(EnterResultFormTXT, self).__init__(*args, **kwargs)
if col_1_name is not None:
self.col_1.label.text = col_1_name
def EnterResultForm(testcase):
if testcase.type == 'txt':
return EnterResultFormTXT(testcase.category.col_1_name)
else:
return EnterResultFormTC(testcase.category.col_1_name)

75
testdays/lib/wiki.py Normal file
View file

@ -0,0 +1,75 @@
#!/usr/bin/python
# wiki.py - wiki interaction module for autoqa
import fedora.client
from urlparse import urljoin
import urllib
import simplejson
def __strip_url(url):
for s in ["https://fedoraproject.org/wiki/", fedora.client.wiki.Wiki().base_url]:
if url.startswith(s):
url = url[len(s):]
return url
def __get_sections(page):
page = __strip_url(page)
w = fedora.client.wiki.Wiki()
params = {'action':'parse',
'format':'json',
'prop':'sections',
'text':'{{:%s}}__TOC__' % page}
result = w.send_request('api.php', req_params=params)
return result['parse']['sections']
def __read_page_section(page, secnum):
page = __strip_url(page)
w = fedora.client.wiki.Wiki()
# w.send_request chokes if it doesn't get json data back, so we can't
# use that.
params = {'action':'raw','templates':'expand','section':secnum,'title':page}
url = urljoin(w.base_url, 'index.php')
url += '?' + urllib.urlencode(params, doseq=True)
section = urllib.urlopen(url)
return section.read()
def get_page_section(page, secname):
page = __strip_url(page)
secnum = None
secname = secname.lower()
for s in __get_sections(page):
if s['line'].lower().strip() == secname:
secnum = s['number']
if not secnum:
return None
return __read_page_section(page, secnum)
def wiki_json_parse(sectiondata):
decoder = simplejson.JSONDecoder()
jsonitems = []
# TODO: this could be more robust. We could parse each line by itself
# and then merge all the valid lines at the end, rather than completely
# dying if there's any invalid data..
for line in sectiondata.split("\n"):
if line.startswith('*'):
jsonitems.append(line[1:].strip())
elif jsonitems:
break
jsondata = '{' + ','.join(jsonitems) + '}'
data = decoder.decode(jsondata)
return data
def get_autoqa_metadata(page):
page = __strip_url(page)
section = get_page_section(page, 'autoqa metadata')
if section is None:
return dict()
return wiki_json_parse(section)
if __name__ == '__main__':
import pprint
page = 'User:Jskladan/Sandbox:TestdayAppTemplate'
print "Fetching metadata from %s" % page
pprint.pprint(get_page_section(page, 'TestdayApp Metadata'))

View file

View file

@ -0,0 +1,79 @@
# Copyright 2014, 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.
#
# Authors:
# Josef Skladanka <jskladan@redhat.com>
import datetime
from testdays import db
TESTCASE_TYPES = ('tc', 'txt')
class Event(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Text)
metadata_url = db.Column(db.Text)
testday_url = db.Column(db.Text)
resultsdb_job_id = db.Column(db.Integer)
created_at = db.Column(db.DateTime, default = datetime.datetime.utcnow)
categories = db.relation('Category', backref='event', order_by='Category.id')
def __init__(self, name, metadata_url, testday_url=None, resultsdb_job_id=None):
self.name = name
self.metadata_url = metadata_url
self.testday_url = testday_url
self.resultsdb_job_id = resultsdb_job_id
class Category(db.Model):
__table_args__ = (
db.Index('category_fk_event_id', 'event_id'),
)
id = db.Column(db.Integer, primary_key=True)
event_id = db.Column(db.Integer, db.ForeignKey('event.id'))
name = db.Column(db.Text)
col_1_name = db.Column(db.Text)
testcases = db.relation('Testcase', backref='category', order_by='Testcase.id')
def __init__(self, name, event=None):
self.name = name
if event is not None:
self.event = event
class Testcase(db.Model):
__table_args__ = (
db.Index('testcase_fk_category_id', 'category_id'),
)
id = db.Column(db.Integer, primary_key=True)
category_id = db.Column(db.Integer, db.ForeignKey('category.id'))
name = db.Column(db.Text)
url = db.Column(db.Text)
type = db.Column(db.Enum(*TESTCASE_TYPES, name='testcase_type'))
def __init__(self, name, url, type, category=None):
self.name = name
self.type = type
self.url = url
if category is not None:
self.category = category

42
testdays/models/user.py Normal file
View file

@ -0,0 +1,42 @@
# Copyright 2014, 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.
#
# Authors:
# Josef Skladanka <jskladan@redhat.com>
from testdays import db
from flask.ext.login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True)
pw_hash = db.Column(db.String(120), unique=False)
def __init__(self, username, password):
self.username = username
self.set_password(password)
def __repr__(self):
return '<User %r>' % self.username
def set_password(self, password):
self.pw_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.pw_hash, password)

View file

@ -0,0 +1,2 @@
//body { padding-top: 70px; }

BIN
testdays/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,55 @@
<!-- inspired by https://gist.github.com/maximebf/3986659 -->
{% macro form_field(field) -%}
{% set with_label = kwargs.pop('with_label', False) -%}
{% set inline_ = '' -%}
{% if field.type == 'BooleanField' %}
<div class="checkbox">
<label>
{{ field(**kwargs) }}
{{ field.label.text|safe }}
</label>
</div>
{% elif field.type == 'RadioField' %}
{% for subfield in field %}
<div class="radio">
<label>
{{ subfield(**kwargs) }}
{{ subfield.label.text|safe }}
</label>
</div>
{% endfor %}
{% else -%}
<div class="form-group {% if field.errors %}has-error{% endif %}">
{% set placeholder = '' -%}
{% if not with_label -%}
{% set placeholder = field.label.text -%}
{% endif -%}
{% set class_ = 'form-control' -%}
{% if field.type == 'FileField' -%}
{% set class_ = class_ + ' input-file' %}
{% endif -%}
{% if with_label -%}
<label for="{{ field.id }}">
{{ field.label.text }}{% if field.flags.required %} *{% endif %}:
</label>
{% endif -%}
{{ field(class_=class_, placeholder=placeholder, **kwargs) }}
{% if field.errors -%}
<span class="help-block">{{ field.errors|join(', ') }}</span>
{% endif -%}
{% if field.description -%}
<p class="help-block">{{ field.description|safe }}</p>
{% endif %}
</div>
{% endif -%}
{% endmacro -%}

View file

@ -0,0 +1,16 @@
{% extends "layout.html" %}
{% from "_formhelpers.html" import form_field %}
{% block body %}
<h2>{{header}}</h2>
<div class="container-fluid">
<form method="post" action="{{form_action}}">
{{ form.csrf_token }}
{{ form_field(form.metadata_url, with_label = True) }}
<div class="form-group">
<input type="submit" class="btn btn-success" />
</div>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,26 @@
{% for category in event.categories %}
=== {{category.name}} ===
{|
! User
! {{category.col_1_name or "Profile"}}
{% for testcase in category.testcases %}
! [{{testcase.url}} {{testcase.name}}]
{% endfor %}
! References
{% for line in results[category.id] %}
|-
| [[User:{{line['username']}}|{{line['username']}}]]
| {{line['profile']}}
{% for results in line['results'] %}
| {% for result in results %}{% if result[0] == 'PASSED' %}{{"{{result|pass}}"}}{% elif result[0] == 'FAILED' %}{{"{{result|fail}}"}}{% elif result[0] == 'INFO' %}{{"{{result|warn}}"}}{% else %}{{ result[0] }}{% endif %}{% if result[1] %}<ref>{% for bug in line['comments'][result[1]-1][0] %}{{"{{bz|"}}{{bug}}{{"}} "}}{% endfor %}{{ line['comments'][result[1]-1][1] }}</ref>{% endif %}{% endfor %}{# result in results #}
{% endfor %}{# results in line #}
| <references/>
{% endfor %}{# line #}
|-
|}
{% endfor %} {# category #}

View file

@ -0,0 +1,13 @@
{% extends "layout.html" %}
{% block body %}
<h1>Welcome to the Secret Admin Interface!</h1>
<a href={{url_for('admin.add_event')}}>Add/Update event</a><br />
<a href={{url_for('admin.export_event')}}>Export event</a><br />
<h2>Need help?</h2>
<a href="https://fedoraproject.org/wiki/QA/SOP_Test_Day_management">Test Day management SOP</a><br />
<a href="https://fedoraproject.org/wiki/QA:TestdayApp">Testday App's wiki page</a>
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "layout.html" %}
{% from "_formhelpers.html" import form_field %}
{% block body %}
<h2>{{header}}</h2>
<div class="container-fluid">
<form method="post" action="{{form_action}}">
{{ form.csrf_token }}
{{ form_field(form.username, with_label = True) }}
{{ form_field(form.col_1, with_label = True) }}
{{ form_field(form.result, with_label = True) }}
{{ form_field(form.bugs, with_label = True) }}
{{ form_field(form.comment, with_label = True) }}
<div class="form-group">
<input type="submit" class="btn btn-success" />
</div>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,11 @@
{% extends "layout.html" %}
{% block body %}
<h1>Welcome to testdays!</h1>
<p>
This is super-awesome page for the Flask Project. You can try logging in (admin/admin), or accessing the <a href="{{ url_for('admin.admin_index') }}">Admin interface</a>.
</p>
{% endblock %}

View file

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ title|default('testdays') }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Bootstrap -->
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
{# <div class="navbar navbar-default navbar-fixed-top">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<span class="navbar-brand"><b>testdays</b></span>
</div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li><a href="{{ url_for('admin.admin_index') }}">Admin</a></li>
</ul>
<!-- <div class="navbar-right">
<input type="button" class="btn btn-default navbar-btn" value="Search" onclick="location.href='';">
</div> -->
</div><!--/.nav-collapse -->
</div><!--/.container -->
</div><!--/.navbar --> #}
<div class="container-fluid">
{% for category, message in get_flashed_messages(with_categories=True) %}
<div class="alert alert-info {% if category == 'error' %} alert-danger{%endif%}">
<button type="button" class="close" data-dismiss="alert">&times;</button>
{{ message }}
</div>
{% endfor %}
</div>
<div class="container-fluid">
{% block body %}
<h1> My First Flask Application! </h1>
{% endblock %}
</div>
<div class="container-fluid">
{% block footer %}
{% endblock %}
</div>
<!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
<script src="//code.jquery.com/jquery.js"></script>
<!-- Include all compiled plugins -->
<script src="//netdna.bootstrapcdn.com/bootstrap/3.3.4/js/bootstrap.min.js"></script>
</body>
</html>

View file

@ -0,0 +1,10 @@
{% extends "layout.html" %}
{% block body %}
<h1>All Events</h1>
{% for event in events %}
<a href="{{ url_for('main.show_event', event_id=event.id)}}">{{ event.name }}</a><br />
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,19 @@
{% extends "layout.html" %}
{% from "_formhelpers.html" import form_field %}
{% block body %}
<div class="row">
<div class="col-md-4">
<form method="post" action="login">
{{ form.csrf_token }}
{{ form.next_page }}
{{ form_field(form.username) }}
{{ form_field(form.password) }}
<input type="submit" class="btn btn-success" value="Login"/>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,106 @@
{% extends "layout.html" %}
{% block body %}
<h1>{{ event.name }}</h1>
More information about the event can be found here:
<a class="external" target="_blank" href="{{event.testday_url}}">
{{event.testday_url}}
</a>
<br />
Go back to <a href="{{url_for('main.events')}}"> List of Events</a>.
<h3>Results</h3>
Clicking on the testcase name will show you the appropriate "how to test" page. <br />
Click on the <strong class="btn btn-success btn-xs">Enter result</strong> button, to enter result. <br />
<div class="btn btn-warning">
<strong>Note:</strong> results are cached and realoaded from the database each 10 seconds.
</div>
{% for category in event.categories %}
<h3>{{category.name}}</h3>
<table class="table table-bordered">
<thead>
<tr>
<th>Username</th>
<th>{{category.col_1_name or "Profile"}}</th>
{% for testcase in category.testcases %}
<th>
<a href="{{testcase.url}}" class="external" target="_blank">
<strong>{{testcase.name}}</strong>
</a>
</th>
{% endfor %}
<th>Comments</th>
</tr>
<tr>
<th></th>
<th></th>
{% for testcase in category.testcases %}
<th>
{% if testcase.name in profile.filled %}
<a href="{{url_for('main.enter_result', event_id=testcase.category.event.id, tc_id=testcase.id)}}" class="btn btn-primary btn-xs ">
<strong>Already entered</strong>
</a>
{% else %}
<a href="{{url_for('main.enter_result', event_id=testcase.category.event.id, tc_id=testcase.id)}}" class="btn btn-success btn-sm ">
<strong>Enter result</strong>
</a>
{% endif %}
</th>
{% endfor %}
<th></th>
</tr>
</thead>
<tbody>
{% for line in results[category.id] %}
<tr>
<td>{{line['username']}}</td>
<td>{{line['profile']}}</td>
{% for results in line['results'] %}
<td>
{% for result in results %}
{%- if result[0] == 'PASSED' -%}
<span class="glyphicon glyphicon-ok text-success" aria-hidden="true"></span>
{%- elif result[0] == 'FAILED' -%}
<span class="glyphicon glyphicon-remove text-danger" aria-hidden="true"></span>
{%- elif result[0] == 'INFO' -%}
<span class="glyphicon glyphicon-warning-sign text-warning" aria-hidden="true"></span>
{%- else -%}
{{ result[0] }}
{%- endif -%}
{%- if result[1] -%}
<sup>[{{result[1]}}]</sup>
{% endif %}
{% endfor %}
</td>
{% endfor %}
<td>
{% for comment in line['comments'] %}
{{loop.index}}.
{% for bug in comment[0] %}
<a class="external" target="_blank" href="https://bugzilla.redhat.com/show_bug.cgi?id={{bug}}">#{{bug}}</a>,
{% endfor %}
{{comment[1]}}
{% if comment[0] or comment[1] %}
<br />
{% endif %}
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endfor %}
{% endblock %}
{% block footer %}
<div align="right">
<a class="external" href="{{event.metadata_url}}">Wiki Metadata</a>
</div>
{% endblock %}