commit for publication

This commit is contained in:
2026-09-17 14:41:22 +01:00
committed by Jack Clifton
commit 0bf030f3c7
26 changed files with 2506 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
instance/
+10
View File
@@ -0,0 +1,10 @@
Start development web server
$ git clone <URL>
$ cd autotest/autotest-ui
$ python -m venv .venv
$ source .venv/bin/activate
$ pip install -r requirements.txt
$ python autotest_ui.py
Go to http://localhost:8080/
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.11-slim
RUN apt-get update && apt-get install -y \
gcc \
libmariadb-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["gunicorn", "--bind", "0.0.0.0:80", "autotest_ui:app"]
+927
View File
@@ -0,0 +1,927 @@
import flask
import flask_sqlalchemy
import sqlalchemy
import sqlalchemy_utils
import flask_login
import werkzeug.security
import datetime
import enum
import utils
app = flask.Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = 'sqlite:///items.db'
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SECRET_KEY"] = "placeholder_a4b5c6d7"
db = flask_sqlalchemy.SQLAlchemy(app)
login_manager = flask_login.LoginManager()
login_manager.init_app(app)
login_manager.login_view = "route_signin"
class UserRoles(enum.IntEnum):
UNDEFINED = 0
READONLY = 10
DEVELOPER = 20
MAINTAINER = 30
ADMIN = 40
@property
def text(self):
return self.name.title()
@classmethod
def is_valid(cls, role_number: int):
return role_number in cls._value2member_map_
class User(flask_login.UserMixin, db.Model):
__tablename__ = "user"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(
sqlalchemy_utils.StringEncryptedType(db.String(64), app.config["SECRET_KEY"]),
nullable=False)
email = db.Column(
sqlalchemy_utils.StringEncryptedType(db.String(120), app.config["SECRET_KEY"]),
nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
user_role_value = db.Column(db.Integer, nullable=False, default=UserRoles.UNDEFINED)
reports = db.relationship("TestReport", backref="author")
@property
def user_role(self):
try:
return UserRoles(self.user_role_value)
except ValueError:
return UserRoles.UNDEFINED
@property
def password(self):
raise AttributeError("Cannot read password")
@password.setter
def password(self, password):
self.password_hash = werkzeug.security.generate_password_hash(password)
def verify_password(self, password):
return werkzeug.security.check_password_hash(self.password_hash, password)
class BoardFamily(db.Model):
__tablename__ = "board_family"
board_family_id = db.Column(db.Integer, primary_key=True)
board_family_prefix = db.Column(db.String(16), unique=True, nullable=False)
boards = db.relationship("Board", backref="family")
class Board(db.Model):
__tablename__ = "board"
board_id = db.Column(db.Integer, primary_key=True)
board_family_id = db.Column(db.Integer,
db.ForeignKey("board_family.board_family_id"), nullable=False)
board_name = db.Column(db.String(32), unique=True, nullable=False)
test_boards = db.relationship("TestBoards", backref="board")
class OperatingSystem(db.Model):
__tablename__ = "operating_system"
os_id = db.Column(db.Integer, primary_key=True)
os_name = db.Column(db.String(32), unique=True, nullable=False)
test_boards = db.relationship("TestBoards", backref="os")
class TestBoards(db.Model):
__tablename__ = "test_boards"
testboard_id = db.Column(db.Integer, primary_key=True)
board_id = db.Column(db.Integer,
db.ForeignKey("board.board_id"), nullable=False)
os_id = db.Column(db.Integer,
db.ForeignKey("operating_system.os_id"), nullable=True) # an OS is not always installed
_mac_addr = db.Column(db.String(18), nullable=False)
test_boards = db.relationship("TestProduct", backref="testboard")
@property
def mac_addr(self):
return self._mac_addr
@mac_addr.setter
def mac_addr(self, new_addr):
self._mac_addr = new_addr
class Project(db.Model):
__tablename__ = "project"
project_id = db.Column(db.Integer, primary_key=True)
project_name = db.Column(db.String(64), unique=True, nullable=False)
versions = db.relationship("ProjectVersion", backref="project")
class ProjectVersion(db.Model):
__tablename__ = "project_version"
project_version_id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.Integer,
db.ForeignKey("project.project_id"), nullable=False)
project_version_major = db.Column(db.Integer, nullable=False)
project_version_minor = db.Column(db.Integer, nullable=False)
project_version_micro = db.Column(db.Integer, nullable=False)
is_hidden = db.Column(db.Boolean, nullable=False)
reports = db.relationship("TestReport", backref="project_version")
__table_args__ = (
sqlalchemy.UniqueConstraint('project_id', 'project_version_major', 'project_version_minor', 'project_version_micro', name='_project_version_uc'),
)
@property
def version_string(self):
return f"v{self.project_version_major}.{self.project_version_minor}.{self.project_version_micro}"
class TestReport(db.Model):
__tablename__ = "test_report"
test_report_id = db.Column(db.Integer, primary_key=True)
author_id = db.Column(db.Integer,
db.ForeignKey("user.id"), nullable=False)
project_version_id = db.Column(db.Integer,
db.ForeignKey("project_version.project_version_id"), nullable=False)
project_version_build = db.Column(db.Integer, nullable=False)
timestamp_start = db.Column(db.DateTime, nullable=False)
timestamp_end = db.Column(db.DateTime, nullable=False)
cached_result_pass = db.Column(db.Integer, nullable=False)
cached_result_fail = db.Column(db.Integer, nullable=False)
tests = db.relationship("TestProduct", backref="report")
class TestProduct(db.Model):
__tablename__ = "test_product"
test_product_id = db.Column(db.Integer, primary_key=True)
test_report_id = db.Column(db.Integer,
db.ForeignKey("test_report.test_report_id"), nullable=False)
testboard_id = db.Column(db.Integer,
db.ForeignKey("test_boards.testboard_id"), nullable=False)
timestamp_start = db.Column(db.DateTime, nullable=False)
timestamp_end = db.Column(db.DateTime, nullable=False)
cached_result_pass = db.Column(db.Integer, nullable=False)
cached_result_fail = db.Column(db.Integer, nullable=False)
test_items = db.relationship("TestItem", backref="test")
class TestItem(db.Model):
__tablename__ = "test_item"
test_item_id = db.Column(db.Integer, primary_key=True)
test_product_id = db.Column(db.Integer,
db.ForeignKey("test_product.test_product_id"), nullable=False)
test_item_name = db.Column(db.String(128), nullable=False)
test_script_version = db.Column(db.String(32), nullable=False)
test_item_result = db.Column(db.Boolean, nullable=False)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
@app.before_request
def load_logged_in_user():
user_id = flask.session.get("user_id")
if user_id is None:
flask.g.user = None
else:
flask.g.user = User.query.get(user_id)
@app.route("/")
def route_home():
return flask.render_template("home.html")
@app.route("/signout")
@flask_login.login_required
def route_logout():
flask_login.logout_user()
return flask.redirect("/signin")
@app.route("/signin", methods=["GET", "POST"])
def route_signin():
if flask.request.method == "GET":
return flask.render_template("signin.html")
username = flask.request.form.get("username", "")
password = flask.request.form.get("password", "")
if not username:
flask.flash("Username is blank", "error")
return flask.redirect("/signin")
if not password:
flask.flash("Password is blank", "error")
return flask.redirect("/signin")
if not utils.VALID in (
utils.validation.username(username),
utils.validation.email(username)):
flask.flash("Username contains illegal characters")
return flask.redirect("/signin")
user = User.query.filter_by(username=username).first()
if not user:
user = User.query.filter_by(email=username).first()
if not user:
flask.flash("Incorrect _Username_ or Password", "error")
return flask.render_template("/signin.html")
if not werkzeug.security.check_password_hash(user.password_hash, password):
flask.flash("Incorrect Username or _Password_", "error")
return flask.render_template("/signin.html")
flask_login.login_user(user)
return flask.redirect("/account")
@app.route("/product-setup")
@flask_login.login_required
def route_product_setup():
families = BoardFamily.query.order_by(BoardFamily.board_family_prefix).all()
products_table = []
for family in families:
products_table.append(
Board.query.filter_by(board_family_id=family.board_family_id).order_by(Board.board_name.desc()).all()
)
return flask.render_template("product_setup.html", families=families, products_table=products_table)
@app.route("/product-setup/add-family", methods=["POST"])
def route_product_setup_add_family():
if flask_login.current_user.user_role < UserRoles.MAINTAINER:
flask.flash("Insufficient privilages", "error")
return flask.redirect("/product-setup")
family_name = flask.request.form.get("family_name", "").strip()
if not family_name:
flask.flash("Family name is null", "error")
return flask.redirect("/product-setup")
if utils.validation.text_numeric_only(family_name, extra_chars="-") == utils.INVALID:
flask.flash("Family name contains illegal characters", "error")
return flask.redirect("/product-setup")
existing_family = BoardFamily.query.filter_by(board_family_prefix=family_name).first()
if existing_family:
flask.flash("Family name already exists", "error")
return flask.redirect("/product-setup")
new_family = BoardFamily(board_family_prefix=family_name)
db.session.add(new_family)
db.session.commit()
return flask.redirect("/product-setup")
@app.route("/product-setup/add-board", methods=["POST"])
@flask_login.login_required
def route_product_setup_add_board():
if flask_login.current_user.user_role < UserRoles.MAINTAINER:
flask.flash("Insufficient privilages", "error")
return flask.redirect("/product-setup")
board_name = flask.request.form.get("board_name", "").strip()
board_family_id = flask.request.form.get("board_family_id", default=0, type=int)
if not board_name:
flask.flash("Board name is null", "error")
return flask.redirect("/product-setup")
if utils.validation.text_numeric_only(board_name) == utils.INVALID:
flask.flash("Board name contains illegal characters", "error")
return flask.redirect("/product-setup")
if board_family_id == 0:
flask.flash("Board family is invalid", "error")
return flask.redirect("/product-setup")
existing_board = Board.query.filter_by(board_name=board_name).first()
if existing_board:
flask.flash("Board name already exists", "error")
return flask.redirect("/product-setup")
new_board = Board(board_family_id=board_family_id, board_name=board_name)
db.session.add(new_board)
db.session.commit()
return flask.redirect("/product-setup")
@app.route("/operating-system-setup")
@flask_login.login_required
def route_operating_system_setup():
operating_systems = OperatingSystem.query.order_by(OperatingSystem.os_name).all()
return flask.render_template("operating_system_setup.html",
operating_systems=operating_systems)
@app.route("/operating-system-setup/add-os", methods=["POST"])
@flask_login.login_required
def route_operating_system_setup_add_os():
if flask_login.current_user.user_role < UserRoles.MAINTAINER:
flask.flash("Insufficient privilages", "error")
return flask.redirect("/operating-system-setup")
os_name = flask.request.form.get("os_name", "").strip()
if not os_name:
flask.flash("OS name is invalid", "error")
return flask.redirect("/operating-system-setup")
if utils.validation.text_numeric_only(os_name, extra_chars=" -.") == utils.INVALID:
flask.flash("OS name contains illegal characters", "error")
return flask.redirect("/operating-system-setup")
os = OperatingSystem.query.filter_by(os_name=os_name).first()
if os is not None:
flask.flash(f"OS already exists: {os_name}", "error")
return flask.redirect("/operating-system-setup")
os = OperatingSystem(os_name=os_name)
db.session.add(os)
db.session.commit()
return flask.redirect("/operating-system-setup")
@app.route("/operating-system-setup/delete-os/<int:os_id>", methods=["POST"])
@flask_login.login_required
def route_operating_system_setup_delete_os(os_id):
if flask_login.current_user.user_role < UserRoles.MAINTAINER:
flask.flash("Insufficient privilages", "error")
return flask.redirect("/operating-system-setup")
operating_system = OperatingSystem.query.filter_by(os_id=os_id).first()
if operating_system is None:
flask.flash("OS not found", "error")
return flask.redirect("/operating-system-setup")
if len(operating_system.test_boards) != 0:
flask.flash("This OS is still in use by a test board", "error")
return flask.redirect("/operating-system-setup")
db.session.delete(operating_system)
db.session.commit()
return flask.redirect("/operating-system-setup")
@app.route("/test-bench-setup")
@flask_login.login_required
def route_test_bench_setup():
families = BoardFamily.query.order_by(BoardFamily.board_family_prefix).all()
boards = Board.query.order_by(Board.board_name).all()
operating_systems = OperatingSystem.query.order_by(OperatingSystem.os_name).all()
test_boards = TestBoards.query.all()
return flask.render_template("test_bench.html",
families=families, boards=boards,
operating_systems=operating_systems,
test_boards=test_boards)
@app.route("/test-bench-setup/add-board", methods=["POST"])
@flask_login.login_required
def route_test_bench_setup_add_board():
if flask_login.current_user.user_role < UserRoles.MAINTAINER:
flask.flash("Insufficient privilages", "error")
return flask.redirect("/test-bench-setup")
board_id = flask.request.form.get("board_id", default=0, type=int)
mac_addr = utils.sanitise.mac_addr(flask.request.form.get("mac_addr", "").strip())
if board_id == 0:
flask.flash("Board ID is invalid", "error")
return flask.redirect("/test-bench-setup")
if not mac_addr:
flask.flash("MAC address is null", "error")
return flask.redirect("/test-bench-setup")
if utils.validation.mac_addr(mac_addr) != utils.VALID:
flask.flash("MAC address is not valid", "error")
return flask.redirect("/test-bench-setup")
new_test_board = TestBoards(board_id=board_id, mac_addr=mac_addr)
db.session.add(new_test_board)
db.session.commit()
return flask.redirect("/test-bench-setup")
@app.route("/test-bench-setup/update-os/<int:testboard_id>", methods=["POST"])
@flask_login.login_required
def route_test_bench_setup_update_os(testboard_id):
if flask_login.current_user.user_role < UserRoles.MAINTAINER:
flask.flash("Insufficient privilages", "error")
return flask.redirect("/test-bench-setup")
os_id = flask.request.form.get("os_id", default=0, type=int)
if testboard_id == 0:
flask.flash("Test baord ID is null", "error")
return flask.redirect("/test-bench-setup")
test_board = TestBoards.query.filter_by(testboard_id=testboard_id).first()
if test_board is None:
flask.flash("Test board not found", "error")
return flask.redirect("/test-bench-setup")
# if we are selecting a non-zero OS, we must check it is valid
# 0 indicated a null value - where no OS is installed
if os_id != 0:
os = OperatingSystem.query.filter_by(os_id=os_id).first()
if os is None:
flask.flash("OS is not found", "error")
return flask.redirect("/test-bench-setup")
test_board.os_id = os_id
db.session.commit()
return flask.redirect("/test-bench-setup")
@app.route("/test-bench-setup/delete-test-board/<int:testboard_id>", methods=["POST"])
@flask_login.login_required
def route_test_bench_setup_delete_test_board(testboard_id):
if flask_login.current_user.user_role < UserRoles.MAINTAINER:
flask.flash("Insufficient privilages", "error")
return flask.redirect("/test-bench-setup")
if testboard_id == 0:
flask.flash("Test board ID is null", "error")
return flask.redirect("/test-bench-setup")
test_board = TestBoards.query.filter_by(testboard_id=testboard_id).first()
if test_board is None:
flask.flash("Test board is not found", "error")
return flask.redirect("/test-bench-setup")
db.session.delete(test_board)
db.session.commit()
return flask.redirect("/test-bench-setup")
@app.route("/schedule-new-test", methods=["GET", "POST"])
@flask_login.login_required
def route_schedule_new_test():
if flask_login.current_user.user_role < UserRoles.DEVELOPER:
flask.flash("Insufficient privilages", "error")
return flask.redirect("/")
if flask.request.method == "GET":
operating_systems = OperatingSystem.query.order_by(OperatingSystem.os_name).all()
test_boards = TestBoards.query.all()
projects = Project.query.all()
projects_dict = {}
for project in projects:
projects_dict[project.project_name] = []
for version in project.versions:
if version.is_hidden:
continue
v_maj = version.project_version_major
v_min = version.project_version_minor
v_mic = version.project_version_micro
ver_str = f"{v_maj}.{v_min}.{v_mic}"
projects_dict[project.project_name].append(ver_str)
return flask.render_template("schedule_new_test.html",
operating_systems=operating_systems, test_boards=test_boards, projects=projects_dict)
# handle post request
project = utils.sanitise.text_input(flask.request.form.get("proj", "").strip())
version = utils.sanitise.text_input(flask.request.form.get("ver", "").strip())
build_num = flask.request.form.get("build", type=int)
test_board_ids = flask.request.form.getlist("test-boards")
# load project id
project_obj = Project.query.filter_by(project_name=project).first()
if project_obj is None:
flask.flash("Cannot load the project", "error")
return flask.redirect("/schedule-new-test")
project_id = project_obj.project_id
generic_version_error_msg = "Version provided is not in the correct format"
# parse project version
if version.count(".") != 2:
flask.flash(generic_version_error_msg, "error")
return flask.redirect("/schedule-new-test")
v_maj, v_min, v_mic = version.split(".")
try:
v_maj = int(v_maj)
v_min = int(v_min)
v_mic = int(v_mic)
except ValueError:
flask.flash(generic_version_error_msg, "error")
return flask.redirect("/schedule-new-test")
version_obj = ProjectVersion.query.filter_by(
project_id=project_id,
project_version_major=v_maj,
project_version_minor=v_min,
project_version_micro=v_mic,
).first()
if version_obj is None:
flask.flash("Cannot load the provided version", "error")
return flask.redirect("/schedule-new-test")
project_version_id = version_obj.project_version_id
# create TestReport record
test_report_obj = TestReport(
author_id = flask_login.current_user.id,
project_version_id = project_version_id,
project_version_build = 4, # placeholder
timestamp_start = datetime.datetime.now(),
timestamp_end = utils.defaults.unix_epoch(),
cached_result_pass = 0, # placeholder
cached_result_fail = 0, # placeholder
)
db.session.add(test_report_obj)
# todo: create TestProduct for each selected board
test_products_objs = []
for test_board_id in test_board_ids:
board_obj = TestBoards.query.filter_by(testboard_id=test_board_id).first()
if board_obj is None:
db.session.rollback()
flask.flash("Cannot cancel this report", "error")
return flask.redirect("/schedule-new-test")
test_board_obj = TestProduct(
test_report_id = test_report_obj.test_report_id,
testboard_id = test_board_id,
timestamp_start = utils.defaults.unix_epoch(),
timestamp_end = utils.defaults.unix_epoch(),
cached_result_pass = 0,
cached_result_fail = 0,
)
test_products_objs.append(test_board_obj)
for test_product in test_products_objs:
db.session.add(test_product)
db.session.commit()
# not fully implemented due to backend relying on internal coporate systems
# this is outside the scope of this assignment
return flask.redirect("/active-tests")
@app.route("/active-tests")
@flask_login.login_required
def route_active_tests():
active_reports = TestReport.query.filter_by(timestamp_end=utils.defaults.unix_epoch()).all()
return flask.render_template("active-tests.html", active_reports=active_reports)
@app.route("/active-tests/delete/<int:report_id>", methods=["POST"])
@flask_login.login_required
def route_active_tests_delete(report_id):
if flask_login.current_user.user_role < UserRoles.DEVELOPER:
flask.flash("Insufficient privilages", "error")
return flask.redirect("/active-tests")
report = TestReport.query.filter_by(test_report_id=report_id, timestamp_end=utils.defaults.unix_epoch()).first()
if report is None:
flask.flash("Cannot cancel this report", "error")
return flask.redirect("/active-tests")
# todo: delete child nodes first
child_test_products = TestProduct.query.filter_by(test_report_id=report.test_report_id).all()
for test_product in child_test_products:
db.session.delete(test_product)
db.session.delete(report)
db.session.commit()
return flask.redirect("/active-tests")
@app.route("/active-tests/<int:report_id>")
@flask_login.login_required
def route_active_tests_products(report_id):
test_products = TestProduct.query.filter_by(test_report_id=report_id).all()
if len(test_products) == 0:
flask.flash(f"Invalid test report ID: {report_id}")
return flask.redirect("/active-tests")
return flask.render_template("active-tests-products.html", report_id=report_id, test_products=test_products)
@app.route("/active-tests/delete-product-test/<int:product_test_id>", methods=["POST"])
@flask_login.login_required
def route_active_tests_delete_product_test(product_test_id):
if flask_login.current_user.user_role < UserRoles.MAINTAINER:
flask.flash("Insufficient privilages", "error")
return flask.redirect(f"/active-tests/{product_test_id}")
test_product = TestProduct.query.filter_by(test_product_id=product_test_id).first()
if test_product is None:
flask.flash("Cannot load record from database")
return flask.redirect(f"/active-tests/{product_test_id}")
test_report_id = test_product.report.test_report_id
db.session.delete(test_product)
db.session.commit()
return flask.redirect(f"/active-tests/{test_report_id}")
@app.route("/test-reports")
@flask_login.login_required
def route_test_reports():
return flask.render_template("test_reports.html")
@app.route("/projects")
@flask_login.login_required
def route_projects():
all_projects = Project.query.order_by(Project.project_name).all()
return flask.render_template("projects.html", projects=all_projects)
@app.route("/projects/add-project", methods=["POST"])
@flask_login.login_required
def route_add_project():
if flask_login.current_user.user_role < UserRoles.MAINTAINER:
flask.flash("Insufficient privilages", "error")
return flask.redirect("/projects")
project_name = flask.request.form.get("project_name", "").strip()
if not project_name:
flask.flash("Project name is null", "error")
return flask.redirect("/projects")
if utils.validation.text_numeric_only(project_name, extra_chars=" -") == utils.INVALID:
flask.flash("Project name contains illegal characters", "error")
return flask.redirect("/projects")
existing_project = Project.query.filter_by(project_name=project_name).first()
if existing_project:
flask.flash("Project with that name already exists", "error")
return flask.redirect("/projects")
new_project = Project(project_name=project_name)
db.session.add(new_project)
db.session.commit()
return flask.redirect("/projects")
@app.route("/projects/add-version", methods=["POST"])
@flask_login.login_required
def route_add_project_version():
if flask_login.current_user.user_role < UserRoles.MAINTAINER:
flask.flash("Insufficient privilages", "error")
return flask.redirect("/projects")
project_id = flask.request.form.get("project_id", type=int)
version_major = flask.request.form.get("major", type=int)
version_minor = flask.request.form.get("minor", type=int)
version_micro = flask.request.form.get("micro", type=int)
if not project_id:
flask.flash("Project ID is not defined", "error")
return flask.redirect("/projects")
if None in (version_major, version_minor, version_micro):
flask.flash("Not all version levels are defined (major minor micro)", "error")
return flask.redirect("/projects")
existing_version = ProjectVersion.query.filter_by(
project_id=project_id,
project_version_major=version_major,
project_version_minor=version_minor,
project_version_micro=version_micro
).first()
if existing_version:
flask.flash("Version already exists for this project", "error")
return flask.redirect("/projects")
new_version = ProjectVersion(
project_id=project_id,
project_version_major=version_major,
project_version_minor=version_minor,
project_version_micro=version_micro,
is_hidden=False
)
db.session.add(new_version)
db.session.commit()
return flask.redirect("/projects")
@app.route("/projects/toggle-version-hidden/<int:project_version_id>", methods=["POST"])
@flask_login.login_required
def route_toggle_version_hidden(project_version_id):
if flask_login.current_user.user_role < UserRoles.MAINTAINER:
flask.flash("Insufficient privilages", "error")
return flask.redirect("/projects")
version = ProjectVersion.query.filter_by(project_version_id=project_version_id).first()
if not version:
flask.flash("Version cannot be loaded from database", "error")
return flask.redirect("/projects")
version.is_hidden = not version.is_hidden
db.session.commit()
return flask.redirect("/projects")
@app.route("/account")
@flask_login.login_required
def route_accounts():
users = User.query.all()
return flask.render_template("account.html", users=users, UserRoles=UserRoles)
@app.route("/account/new", methods=["GET", "POST"])
@flask_login.login_required
def route_account_new():
if flask_login.current_user.user_role < UserRoles.ADMIN:
flask.flash("Insufficient privilages", "error")
return flask.redirect("/account")
if flask.request.method == "POST":
username = flask.request.form.get("username")
email = flask.request.form.get("email")
password = flask.request.form.get("password")
usertype = flask.request.form.get("user-type", type=int)
if None in (username, email, password):
flask.flash("fields cannot be blank", "error")
return flask.redirect("/account/new")
if utils.INVALID == utils.validation.username(username):
flask.flash("Username contains illegal characters", "error")
return flask.redirect("/account/new")
if utils.INVALID == utils.validation.email(email):
flask.flash("Email contains illegal characters", "error")
return flask.redirect("/account/new")
users_via_username = User.query.filter_by(username=username).first()
users_via_email = User.query.filter_by(email=email).first()
if (users_via_username is not None) or (users_via_email is not None):
flask.flash("That user already exists", "error")
return flask.redirect("/account/new")
if not UserRoles.is_valid(usertype):
flask.flash("Invalid user role")
return flask.redirect("/account/new")
# todo actually create the new user
user = User(
username=username,
email=email,
password=password,
user_role_value=usertype
)
db.session.add(user)
db.session.commit()
return flask.redirect("/account")
return flask.render_template("account-new.html", UserRoles=UserRoles)
@app.route("/account/update", methods=["POST"])
@flask_login.login_required
def route_account_update():
if flask_login.current_user.user_role < UserRoles.ADMIN:
flask.flash("Insufficient privilages", "error")
return flask.redirect("/account")
user_id = flask.request.form.get("user-id", type=int)
userrole = flask.request.form.get("user-type", type=int)
user_obj = User.query.filter_by(id=user_id).first()
if user_obj is None:
flask.flash("Cannot load that user", "error")
return flask.redirect("/account")
if not UserRoles.is_valid(userrole):
flask.flash("User role is not valid", "error")
return flask.redirect("/account")
if user_obj.user_role_value == userrole:
flask.flash("User role has not been changed", "error")
return flask.redirect("/account")
user_obj.user_role_value = userrole
db.session.commit()
with app.app_context():
db.create_all()
# debug create initial account
if not User.query.first():
initial_user = User(
username="admin",
email="admin@localhost",
password="admin",
user_role_value=UserRoles.ADMIN
)
db.session.add(initial_user)
db.session.commit()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
+9
View File
@@ -0,0 +1,9 @@
Flask
Flask-SQLAlchemy
Flask-Login
SQLAlchemy-Utils
cryptography
requests
pymysql
gunicorn
pytest
+41
View File
@@ -0,0 +1,41 @@
:root {
/* Backgrounds */
--bg-app: #121214;
--bg-surface: #1a1a1e;
--bg-nav-item-hover: #2a2a30;
/* Borders */
--border-color: #2d2d34;
/* Text */
--text-primary: #f0f0f5;
--text-muted: #a0a0b0;
/* Accents & Buttons */
--accent: #38bdf8; /* Vibrant sky blue */
--accent-hover: #7dd3fc;
--auth-bg: #22c55e; /* Success green for sign in/out */
--auth-text: #ffffff;
}
/* Core Element Bindings */
body {
background-color: var(--bg-app);
color: var(--text-primary);
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
color: var(--accent-hover);
}
.error-text {
color: red;
}
+159
View File
@@ -0,0 +1,159 @@
body {
margin: 0px;
}
main {
margin: 20px;
}
header {
padding: 20px;
}
header h1 {
margin: 0px;
}
header nav {
text-align: center;
}
header nav a, .user-account-btn {
padding: 5px;
margin-left: 5px;
margin-right: 5px;
padding-left: 10px;
padding-right: 10px;
border-radius: 10px;
text-decoration: none;
}
button {
margin-left: 15px;
padding: 5px 10px;
}
textarea {
box-sizing: border-box;
width: calc(100% - 10px);
margin-top: 25px;
margin-left: 5px;
margin-right: 5px;
padding: 5px;
}
.user-account-btn {
position: absolute;
right: 0px;
top: 50%;
transform: translateY(-50%);
}
form.account-input {
margin-bottom: 50px;
}
form.account-input input {
display: block;
margin-bottom: 10px;
min-width: 250px;
}
form.account-input button {
margin-top: 15px;
}
#review-table th {
text-align: right;
}
#review-table td {
padding-left: 10px;
}
#review-table * {
padding: 2px;
}
.segmented-control {
display: inline-flex;
border-radius: 6px;
overflow: hidden;
}
.segmented-control input[type="checkbox"] {
display: none;
}
.segmented-control label {
padding: 8px 16px;
cursor: pointer;
user-select: none;
transition: background-color 0.2s, color 0.2s;
}
.segmented-control label:last-child {
border-right: none;
}
.segmented-control input[type="checkbox"]:checked + label {
font-weight: bold;
}
.segmented-control input[type="checkbox"]:disabled + label {
cursor: not-allowed;
opacity: 0.6;
pointer-events: none;
}
label {
display:block;
margin-bottom:5px;
font-weight:600;
}
form div {
margin-bottom: 10px;
}
form * button {
margin-top: 30px;
}
table {
border-spacing: 0px;
}
table td {
padding: 2px 20px;
text-align: center;
outline: none;
}
table.vertical-table th {
text-align: right;
}
table.vertical-table td {
text-align: left;
}
.clickable {
cursor: pointer;
}
+13
View File
@@ -0,0 +1,13 @@
document.addEventListener("DOMContentLoaded", () => {
const rows = document.querySelectorAll(".clickable");
rows.forEach(row => {
row.addEventListener("click", () => {
window.location.href = row.dataset.href;
})
})
})
+48
View File
@@ -0,0 +1,48 @@
const VALID = true;
const INVALID = false;
class validation {
static mac_addr(addr) {
const VALID_MAC_CHARS = "0123456789abcdefABCDEF";
if (addr.length !== 17) {
return INVALID;
}
const COLON_START_IDX = 2;
const COLON_STEP = 3;
for (let idx = COLON_START_IDX; idx < addr.length; idx += COLON_STEP) {
if (addr[idx] != ":") {
return INVALID;
}
}
const mac_sections = addr.split(":");
if (mac_sections.length !== 6) {
return INVALID;
}
for (const mac_byte of mac_sections) {
if (mac_byte.length !== 2) {
return INVALID;
}
for (const char of mac_byte) {
if (!VALID_MAC_CHARS.includes(char)) {
return INVALID;
}
}
}
return VALID;
}
}
+41
View File
@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block body %}
{% if current_user.user_role >= UserRoles.ADMIN %}
<div>
<form action="/account/new" method="POST">
<div>
<label>Username:</label>
<input name="username" type="text" placeholder="Username" />
</div>
<div>
<label>Email:</label>
<input name="email" type="text" placeholder="Email" />
</div>
<div>
<label>Password:</label>
<input name="password" type="password" placeholder="Temporary Password" />
</div>
<div>
<label>User Type:</label>
<select name="user-type">
{% for role in UserRoles %}
<option value="{{ role }}">
{{ role.text }}
</option>
{% endfor %}
</select>
</div>
<div>
<button type="submit" style="margin-top: 20px;">Create</button>
</div>
</form>
</div>
{% endif %}
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block body %}
<div>
<h2>My Account</h2>
<table class="vertical-table" style="margin-bottom: 40px;">
<tr>
<th>First Name:</th>
<td>{{ current_user.username }}</input></td>
</tr>
<tr>
<th>Email:</th>
<td>{{ current_user.email }}</input></td>
</tr>
<tr>
<th>User Type:</th>
<td>{{ current_user.user_role.text }}</td>
</tr>
</table>
</div>
{% if current_user.user_role >= UserRoles.ADMIN %}
<div>
<div style="margin-bottom: 20px; text-decoration: none; cursor: pointer;">
<a href="/account/new">Create User</a>
</div>
<table>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>User Type</th>
<th>Save</th>
</tr>
{% for user in users %}
<tr>
<form id="form-{{ user.id }}" method="POST" action="/account/update">
<div style="display: none;">
<input name="user-id" value="{{ user.id }}" />
</div>
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
{% if current_user.user_role >= UserRoles.ADMIN %}
<td>
<select name="user-type" oninput="document.getElementById('save-btn-{{ user.id }}').removeAttribute('disabled');">
{% for role in UserRoles %}
<option value="{{ role.text }}" {% if role == user.user_role %} selected {% endif %}>
{{ role.text }}
</option>
{% endfor %}
</select>
</td>
<td><button id="save-btn-{{ user.id }}" type="submit" disabled>Save</button></td>
{% else %}
<td>{{ user.usertype.type }}</td>
{% endif %}
</form>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% endblock %}
+102
View File
@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}Test Bench Setup - Autotest{% endblock %}
{% block body %}
<div class="setup-container" style="display: flex; gap: 0px; align-items: flex-start; margin-left: 100px;">
<div>
<h2>Active Tests - {{ report_id }}</h2>
<div style="margin-bottom: 10px;">
<a href="/active-tests"><- Go Back</a>
</div>
<table style="width: 100%; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; background-color: var(--bg-surface);">
<thead>
<tr style="background-color: var(--bg-active); border-bottom: 1px solid var(--border);">
<th style="padding: 12px; text-align: left;">ID</th>
<th style="padding: 12px; text-align: left;">Author</th>
<th style="padding: 12px; text-align: left;">Project</th>
<th style="padding: 12px; text-align: left;">Version</th>
<th style="padding: 12px; text-align: left;">Board</th>
<th style="padding: 12px; text-align: left;">OS</th>
<th style="padding: 12px; text-align: left;">Date</th>
<th style="padding: 12px; text-align: left;">Start Time</th>
<th style="padding: 12px; text-align: left;">End Time</th>
<th style="padding: 12px; text-align: left;">Passed</th>
<th style="padding: 12px; text-align: left;">Failed</th>
<th style="padding: 12px; text-align: center; width: 1%; white-space: nowrap;">Delete</th>
</tr>
</thead>
<tbody>
{% for board in test_products %}
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ board.test_product_id }}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ board.report.author.username }}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ board.report.project_version.project.project_name }}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ board.report.project_version.version_string }}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ board.testboard.board.family.board_family_prefix }}{{ board.testboard.board.board_name }}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ board.testboard.os.os_name }}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ board.timestamp_start.strftime("%d %b %Y") }}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ board.timestamp_start.strftime("%H:%M:%S") }}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ board.timestamp_end.strftime("%H:%M:%S") }}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
0
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
0
</td>
<td>
<form action="/active-tests/delete-product-test/{{ board.test_product_id }}" method="POST">
<button type="submit">X</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="10" style="padding: 20px; text-align: center; color: var(--text-muted);">No tests are in progress.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}Test Bench Setup - Autotest{% endblock %}
{% block body %}
<div class="setup-container" style="display: flex; gap: 0px; align-items: flex-start; margin-left: 100px;">
<div>
<h2>Active Tests</h2>
<table style="width: 100%; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; background-color: var(--bg-surface);">
<thead>
<tr style="background-color: var(--bg-active); border-bottom: 1px solid var(--border);">
<th style="padding: 12px; text-align: left;">ID</th>
<th style="padding: 12px; text-align: left;">Author</th>
<th style="padding: 12px; text-align: left;">Project</th>
<th style="padding: 12px; text-align: left;">Version</th>
<th style="padding: 12px; text-align: left;">Date</th>
<th style="padding: 12px; text-align: left;">Start Time</th>
<th style="padding: 12px; text-align: left;">Status</th>
<th style="padding: 12px; text-align: center; width: 1%; white-space: nowrap;">Delete</th>
</tr>
</thead>
<tbody>
{% for report in active_reports %}
<tr style="border-bottom: 1px solid var(--border);" class="clickable" data-href="/active-tests/{{ report.test_report_id }}">
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ report.test_report_id }}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ report.author.username }}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ report.project_version.project.project_name }}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ report.project_version.version_string }}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ report.timestamp_start.strftime("%d %b %Y") }}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ report.timestamp_start.strftime("%H:%M:%S") }}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
In Progress
</td>
<td>
<form action="/active-tests/delete/{{ report.test_report_id }}" method="POST">
<button type="submit">X</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" style="padding: 20px; text-align: center; color: var(--text-muted);">No tests are in progress.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='utils/clickable.js') }}"></script>
{% endblock %}
+94
View File
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Autotest{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='layout.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='colour-default.css') }}">
</head>
<body>
<header class="header-container">
<h1>Autotest</h1>
{% if current_user.is_authenticated %}
<a href="/account" class="user-account-btn" style="text-align: right;">
{{ current_user.f_name }} {{ current_user.s_name[0] if current_user.s_name else "" }}
</a>
{% endif %}
<nav>
<a href="/">Home</a>
<a href="/operating-system-setup">Operating Systems</a>
<a href="/projects">Projects</a>
<a href="/product-setup">Products</a>
<a href="/test-bench-setup">Test Bench</a>
<a href="/schedule-new-test">Schedule Test</a>
<a href="/active-tests">Active Tests</a>
<a href="/test-reports">Reports</a>
<a href="/account">Account</a>
{% if current_user.is_authenticated %}
<a href="/signout">Sign out</a>
{% else %}
<a href="/signin">Sign in</a>
{% endif %}
</nav>
</header>
<main>
{% block body %}{% endblock %}
</main>
<footer>
</footer>
<div id="flash-popup-container" style="position: fixed; top: 120px; left: 50%; transform: translateX(-50%); z-index: 1000; display: flex; flex-direction: column; gap: 10px; width: 100%; max-width: 400px; padding: 0 20px; box-sizing: border-box;">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash-popup {{ category }}" style="min-width: 280px; max-width: 400px; background-color: #e63946; color: white; padding: 14px 18px; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); font-family: sans-serif; display: flex; justify-content: space-between; align-items: center; gap: 12px; animation: popupFadeIn 0.3s ease;">
<span style="font-size: 14px; font-weight: 500; line-height: 1.4;">{{ message }}</span>
<button onclick="this.parentElement.remove()" style="background: none; border: none; color: white; cursor: pointer; font-size: 20px; font-weight: bold; padding: 0; line-height: 1; opacity: 0.8;">&times;</button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<style>
@keyframes popupFadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
<script>
document.addEventListener("DOMContentLoaded", function() {
const popups = document.querySelectorAll('.flash-popup');
popups.forEach(function(popup) {
// Automatically fade and remove the popup after 5 seconds
setTimeout(function() {
popup.style.transition = "opacity 0.5s ease, transform 0.5s ease";
popup.style.opacity = "0";
popup.style.transform = "translateY(-10px)";
setTimeout(function() {
popup.remove();
}, 500);
}, 5000);
});
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block body %}
<h1>
testing home page
</h1>
{% endblock %}
+51
View File
@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}Operating System Setup - Autotest{% endblock %}
{% block body %}
<div class="setup-container" style="display: flex; gap: 0px; align-items: flex-start; margin-left: 100px;">
<div class="forms-column" style="flex: 1; min-width: 300px;">
<section class="setup-section" style="margin-bottom: 40px;">
<h2>Add Operating System</h2>
<form action="/operating-system-setup/add-os" method="POST" class="account-input">
<input type="text" name="os_name" placeholder="Operating System" required>
<button type="submit">Add OS</button>
</form>
</section>
</div>
<div class="table-column" style="flex: 2;">
<h2>Operating Systems</h2>
<table style="width: 100%; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; background-color: var(--bg-surface);">
<thead>
<tr style="background-color: var(--bg-active); border-bottom: 1px solid var(--border);">
<th style="padding: 12px; text-align: left;">Operating System</th>
<th style="padding: 12px; text-align: center; width: 1%; white-space: nowrap;">Delete</th>
</tr>
</thead>
<tbody>
{% for os in operating_systems %}
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ os.os_name }}
</td>
<td>
<form action="/operating-system-setup/delete-os/{{ os.os_id }}" method="POST">
<button type="submit">X</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="1" style="padding: 20px; text-align: center; color: var(--text-muted);">No products registered yet. Add one on the left.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+70
View File
@@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block title %}Product Setup - Autotest{% endblock %}
{% block body %}
<div class="setup-container" style="display: flex; gap: 0px; align-items: flex-start; margin-left: 100px;">
<div class="forms-column" style="flex: 1; min-width: 300px;">
<section class="setup-section" style="margin-bottom: 40px;">
<h2>Add Board Family</h2>
<form action="/product-setup/add-family" method="POST" class="account-input">
<input type="text" name="family_name" placeholder="e.g. DPX-S" required>
<button type="submit">Add Family</button>
</form>
</section>
<section class="setup-section">
<h2>Add New Board</h2>
<form action="/product-setup/add-board" method="POST" class="account-input">
<label style="display:block; margin-bottom:5px; font-weight:600;">Board Family:</label>
<select name="board_family_id" style="display: block; margin-bottom: 10px; min-width: 250px; background-color: var(--bg-surface); color: var(--text-main); border: 1px solid var(--border); padding: 5px; border-radius: 6px;" required>
<option value="" disabled selected>Select a Family</option>
{% for family in families %}
<option value="{{ family.board_family_id }}">{{ family.board_family_prefix }}</option>
{% endfor %}
</select>
<input type="text" name="board_name" placeholder="Board Name">
<button type="submit">Add Product</button>
</form>
</section>
</div>
<div class="table-column" style="flex: 2;">
<h2>Products</h2>
<table style="width: 100%; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; background-color: var(--bg-surface);">
<thead>
<tr style="background-color: var(--bg-active); border-bottom: 1px solid var(--border);">
<th style="padding: 12px; text-align: left;">Product Families</th>
<th style="padding: 12px; text-align: left;">Boards</th>
</tr>
</thead>
<tbody>
{% for boards in products_table %}
{% for board in boards %}
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{% if loop.first %}{{ board.family.board_family_prefix }}{% endif %}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ board.family.board_family_prefix }}{{ board.board_name}}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="1" style="padding: 20px; text-align: center; color: var(--text-muted);">No products registered yet. Add one on the left.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+90
View File
@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block body %}
<div style="margin-bottom: 40px;">
<h2>Create New Project</h2>
<form action="/projects/add-project" method="POST" class="account-input">
<input type="text" name="project_name" placeholder="Project Name" required>
<button type="submit">Create Project</button>
</form>
</div>
<div>
<h2>Create New Version</h2>
<form action="/projects/add-version" method="POST" class="account-input">
<label style="display:block; margin-bottom:5px; font-weight:600;">Project Name:</label>
<select name="project_id" style="display: block; margin-bottom: 15px; min-width: 250px; background-color: var(--bg-surface); color: var(--text-main); border: 1px solid var(--border); padding: 8px; border-radius: 6px;" required>
<option value="" disabled selected>Select project...</option>
{% for project in projects %}
<option value="{{ project.project_id }}">{{ project.project_name }}</option>
{% endfor %}
</select>
<label style="display:block; margin-bottom:5px; font-weight:600;">Semantic Versioning Configuration:</label>
<div>
<input type="number" name="major" placeholder="Major" min="0" style="min-width: 70px; width: 70px;" required>
<input type="number" name="minor" placeholder="Minor" min="0" style="min-width: 70px; width: 70px;" required>
<input type="number" name="micro" placeholder="Micro" min="0" style="min-width: 70px; width: 70px;" required>
</div>
<button type="submit">Create Version</button>
</form>
</div>
<div class="display-panel">
<h2>Project Registry</h2>
{% for project in projects %}
<div class="project-card" style="background-color: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 25px;">
<h3 style="margin-top: 0; color: var(--accent); border-bottom: 1px solid var(--border); padding-bottom: 10px;">
{{ project.project_name }}
</h3>
<table style="width: 100%;">
<thead>
<tr style="color: var(--text-muted); font-size: 0.85rem;">
<th style="padding: 8px;">Version</th>
<th style="padding: 8px;">Status</th>
<th style="padding: 8px; text-align: right;">Visibility</th>
</tr>
</thead>
<tbody>
<!-- Loop through the project's versions sorted by semantic order using its relation helper -->
{% for version in project.versions | sort(attribute='project_version_major', reverse=True) %}
<tr style="border-top: 1px solid var(--border); opacity: {% if version.is_hidden %}0.5{% else %}1{% endif %};">
<td style="padding: 10px 8px; font-weight: 500;">
v{{ version.project_version_major }}.{{ version.project_version_minor }}.{{ version.project_version_micro }}
</td>
<td style="padding: 10px 8px; font-weight: 500;">
{% if version.is_hidden %}
<span style="font-size: 0.75rem; background-color: #ef4444; color: white; padding: 2px 6px; border-radius: 4px; margin-left: 10px;">HIDDEN</span>
{% else %}
<span style="font-size: 0.75rem; background-color: #22c55e; color: white; padding: 2px 6px; border-radius: 4px; margin-left: 10px;">ACTIVE</span>
{% endif %}
</td>
<td style="padding: 10px 8px; text-align: right;">
<!-- Uses your custom Segmented Control layout block for status transitions -->
<form action="/projects/toggle-version-hidden/{{ version.project_version_id }}" method="POST">
<div class="segmented-control">
<input type="checkbox" name="visibility_status" id="vis_{{ version.project_version_id }}_active" {% if version.is_hidden %}checked{% endif %} onchange="this.form.submit()">
<label for="vis_{{ version.project_version_id }}_active">{% if version.is_hidden %}Show{% else %}Hide{% endif %}</label>
</div>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="2" style="padding: 15px 8px; color: var(--text-muted); font-style: italic;">No semantic versions published for this asset profile.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p style="color: var(--text-muted); background-color: var(--bg-surface); padding: 20px; border-radius: 6px; border: 1px solid var(--border); text-align: center;">
No tracked projects found in database engine. Use creation module to register systems.
</p>
{% endfor %}
{% endblock %}
+146
View File
@@ -0,0 +1,146 @@
{% extends "base.html" %}
{% block title %}Test Bench Setup - Autotest{% endblock %}
{% block body %}
<div class="setup-container">
<form action="/schedule-new-test" method="POST">
<!-- project -->
<div>
<select id="proj" name="proj" onchange="oninput_proj();">
<option selected disabled>Project</option>
{% for proj in projects %}
<option value="{{ proj }}">{{ proj }}</option>
{% endfor %}
</select>
</div>
<!-- version -->
<div>
<select id="ver" name="ver" onchange="oninput_ver();">
<option selected disabled>Version</option>
</select>
</div>
<!-- build -->
<div>
<select id="build" name="build">
<option selected disabeld>Build</option>
</select>
</div>
<!-- products / test boards -->
<div>
<table>
<tr>
<th></th>
<th>Board Name</th>
<th>Operating System</th>
<th>MAC Address</th>
</tr>
{% for board in test_boards %}
<tr>
<td><input name="test-boards" type="checkbox" value="{{ board.testboard_id }}"></td>
<td>{{ board.board.family.board_family_prefix }}{{ board.board.board_name }}</td>
<td>{{ board.os.os_name }}</td>
<td>{{ board.mac_addr }}</td>
</tr>
{% else %}
<tr>
<td colspan="4" style="padding: 20px; text-align: center; color: var(--text-muted);">No board have been added to the test bench.</td>
</tr>
{% endfor %}
</table>
</div>
<!-- submit btn -->
<button type="submit">Schedule Test Run</button>
</form>
</div>
{% endblock %}
{% block scripts %}
<style>
form div select {
min-width: 100px;
}
</style>
<script>
const projects_json = {{ projects|tojson }};
const obj_selected = document.getElementById("proj");
const obj_ver = document.getElementById("ver");
const obj_build = document.getElementById("build");
function oninput_proj() {
const selected_proj = obj_selected.value;
const versions_list = projects_json[selected_proj];
obj_ver.innerHTML = "";
versions_list.forEach(function(version) {
const option = document.createElement("option");
option.value = version;
option.textContent = version;
obj_ver.appendChild(option);
})
}
function oninput_ver() {
// not implemented due to backend relying on internal coporate systems
// this is outside the scope of this assignment
// this will query the internal build archive to load a list of available builds
obj_build.innerHTML = "";
const versions_list = [
"example_x.y_z_1",
"example_x.y_z_2",
"example_x.y_z_3",
"example_x.y_z_4",
"example_x.y_z_5",
"example_x.y_z_6",
"example_x.y_z_7",
"example_x.y_z_8",
"example_x.y_z_9",
];
let i = 1;
versions_list.forEach(function(version) {
const option = document.createElement("option");
option.value = i; // example value
option.textContent = version;
obj_build.appendChild(option);
i++;
})
}
</script>
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block body %}
<div style="margin-bottom: 40px;">
<h2>Sign in</h2>
<form action="/signin" method="POST" class="account-input">
<input type="text" name="username" placeholder="Username or Email" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Sign in</button>
</form>
</div>
{% endblock %}
+115
View File
@@ -0,0 +1,115 @@
{% extends "base.html" %}
{% block title %}Test Bench Setup - Autotest{% endblock %}
{% block body %}
<div class="setup-container" style="display: flex; gap: 0px; align-items: flex-start; margin-left: 100px;">
<div class="forms-column" style="flex: 1; min-width: 300px;">
<section class="setup-section">
<h2>Add Board</h2>
<form id="addBoardForm" action="/test-bench-setup/add-board" method="POST" class="account-input">
<label style="display:block; margin-bottom:5px; font-weight:600;">Board Family:</label>
<select name="board_id" style="display: block; margin-bottom: 10px; min-width: 250px; background-color: var(--bg-surface); color: var(--text-main); border: 1px solid var(--border); padding: 5px; border-radius: 6px;" required>
<option value="" disabled selected>Select a Board</option>
{% for board in boards %}
<option value="{{ board.board_id }}">{{ board.family.board_family_prefix }}{{ board.board_name }}</option>
{% endfor %}
</select>
<input type="text" id="mac_addr" name="mac_addr" placeholder="MAC Address" required>
<div id="mac_err" class="error-text"></div>
<button type="submit">Add Test Board</button>
</form>
</section>
</div>
<div class="table-column" style="flex: 2;">
<h2>Test Units</h2>
<table style="width: 100%; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; background-color: var(--bg-surface);">
<thead>
<tr style="background-color: var(--bg-active); border-bottom: 1px solid var(--border);">
<th style="padding: 12px; text-align: left;">Board</th>
<th style="padding: 12px; text-align: left;">MAC Address</th>
<th style="padding: 12px; text-align: left;">OS</th>
<th style="padding: 12px; text-align: center; width: 1%; white-space: nowrap;">Delete</th>
</tr>
</thead>
<tbody>
{% for test_unit in test_boards %}
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ test_unit.board.family.board_family_prefix }}{{ test_unit.board.board_name }}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
{{ test_unit.mac_addr }}
</td>
<td style="padding: 12px; text-align: left; font-weight: 600; color: var(--text-muted);">
<form action="/test-bench-setup/update-os/{{ test_unit.testboard_id }}" method="POST">
<select name="os_id" onchange="this.form.submit()">
<option value="0">No OS installed</option>
{% for os in operating_systems %}
<option value="{{ os.os_id}}" {% if os.os_id == test_unit.os.os_id %} selected {% endif %}>
{{ os.os_name }}
</option>
{% endfor %}
</select>
</form>
</td>
<td>
<form action="/test-bench-setup/delete-test-board/{{ test_unit.testboard_id }}" method="POST">
<button type="submit">X</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="1" style="padding: 20px; text-align: center; color: var(--text-muted);">No products registered yet. Add one on the left.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='utils/validation.js') }}"></script>
<script>
addBoardForm.addEventListener('submit', function(event) {
const mac_input = mac_addr.value;
const is_valid = validation.mac_addr(mac_input);
if (is_valid === false) {
event.preventDefault();
const err_msg = "Error: MAC address input is not valid";
console.error(err_msg);
mac_err.textContent = err_msg
}
});
</script>
{% endblock %}
+293
View File
@@ -0,0 +1,293 @@
import pytest
import autotest_ui
import flask
@pytest.fixture
def client():
autotest_ui.app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
autotest_ui.app.config['TESTING'] = True
with autotest_ui.app.test_client() as client:
with autotest_ui.app.app_context():
autotest_ui.db.create_all()
yield client
autotest_ui.db.drop_all()
# database helper functions
def setup_create_user_admin(client):
with autotest_ui.app.app_context():
admin_user = autotest_ui.User(
username="admin",
email="admin@localhost",
password="admin",
user_role_value=autotest_ui.UserRoles.ADMIN
)
autotest_ui.db.session.add(admin_user)
autotest_ui.db.session.commit()
def init_database(client):
setup_create_user_admin(client)
# login helper function
def login_as_admin(client):
return client.post("/signin", data={
"username": "admin",
"password": "admin"
}, follow_redirects=True)
# test functions
class Test_home:
def test_page_loads(self, client):
init_database(client)
response_logged_out = client.get("/")
assert response_logged_out.status_code == 200
login_as_admin(client)
response_logged_in = client.get("/")
assert response_logged_in.status_code == 200
class Test_signin:
def test_signin(self, client):
init_database(client)
# ensure the user page doesn't load when logged out
response = client.get("/account")
assert response.status_code == 302
assert "/signin" in response.headers.get("Location")
# ensure the page loads
response_logged_out = client.get("/signin")
assert response_logged_out.status_code == 200
# actually login
login_as_admin(client)
# check the account page loads
response = client.get("/account")
assert response.status_code == 200
# signing out redirects to signin page
response = client.get("/signout")
assert response.status_code == 302
assert "/signin" in response.headers.get("Location")
# ensure the uses is actually logged out
response = client.get("/account")
assert response.status_code == 302
assert "/signin" in response.headers.get("Location")
with autotest_ui.app.app_context():
with client.session_transaction() as session:
flashed_messages = flask.get_flashed_messages(with_categories=True)
error_messages = [msg for cat, msg in flashed_messages if cat == "error"]
assert len(error_messages) == 0, f"Unexpected errors found: {error_messages}"
class Test_product_setup:
def test_page_loads(self, client):
init_database(client)
login_as_admin(client)
response = client.get("/product-setup")
assert response.status_code == 200
with autotest_ui.app.app_context():
with client.session_transaction() as session:
flashed_messages = flask.get_flashed_messages(with_categories=True)
error_messages = [msg for cat, msg in flashed_messages if cat == "error"]
assert len(error_messages) == 0, f"Unexpected errors found: {error_messages}"
def test_add_family(self, client):
init_database(client)
login_as_admin(client)
response = client.post("/product-setup/add-family", data={
"family_name": "DPX-S"
})
assert response.status_code == 302
response = client.post("/product-setup/add-family", data={
"family_name": "DPX-M"
})
assert response.status_code == 302
with autotest_ui.app.app_context():
with client.session_transaction() as session:
flashed_messages = flask.get_flashed_messages(with_categories=True)
error_messages = [msg for cat, msg in flashed_messages if cat == "error"]
assert len(error_messages) == 0, f"Unexpected errors found: {error_messages}"
response = client.post("/product-setup/add-family", data={
"family_name": ""
})
assert response.status_code == 302
response = client.post("/product-setup/add-family")
assert response.status_code == 302
with autotest_ui.app.app_context():
with client.session_transaction() as session:
flashed_messages = flask.get_flashed_messages(with_categories=True)
error_messages = [msg for cat, msg in flashed_messages if cat == "error"]
assert len(error_messages) == 2, f"Expected 2 errors. Errors found: {error_messages}"
with autotest_ui.app.app_context():
inserted_family = autotest_ui.BoardFamily.query.filter_by(
board_family_prefix="DPX-S"
).first()
assert inserted_family is not None, "The record was not found in the database"
assert inserted_family.board_family_prefix == "DPX-S"
def test_add_board(self, client):
init_database(client)
login_as_admin(client)
response = client.post("/product-setup/add-family", data={
"family_name": "DPX-S"
})
assert response.status_code == 302
with autotest_ui.app.app_context():
inserted_boards = autotest_ui.BoardFamily.query.filter_by(
board_family_prefix="DPX-S"
).first()
response = client.post("/product-setup/add-board", data={
"board_name": "450",
"board_family_id": inserted_boards.board_family_id
})
assert response.status_code == 302
with autotest_ui.app.app_context():
with client.session_transaction() as session:
flashed_messages = flask.get_flashed_messages(with_categories=True)
error_messages = [msg for cat, msg in flashed_messages if cat == "error"]
assert len(error_messages) == 0, f"Unexpected errors found: {error_messages}"
class Test_operating_system:
def test_page_loads(self, client):
init_database(client)
login_as_admin(client)
response = client.get("/operating-system-setup")
assert response.status_code == 200
def test_add_os(self, client):
init_database(client)
login_as_admin(client)
client.post("/operating-system-setup/add-os", data={ "os_name": "Windows 10" })
client.post("/operating-system-setup/add-os", data={ "os_name": "Windows 11" })
client.post("/operating-system-setup/add-os", data={ "os_name": "Ubuntu 22" })
client.post("/operating-system-setup/add-os", data={ "os_name": "Ubuntu 24" })
client.post("/operating-system-setup/add-os", data={ "os_name": "Ubuntu 26" })
client.post("/operating-system-setup/add-os", data={ "os_name": "Fedora 43" })
client.post("/operating-system-setup/add-os", data={ "os_name": "Fedora 44" })
with autotest_ui.app.app_context():
with client.session_transaction() as session:
flashed_messages = flask.get_flashed_messages(with_categories=True)
error_messages = [msg for cat, msg in flashed_messages if cat == "error"]
assert len(error_messages) == 0, f"Unexpected errors found: {error_messages}"
# test invalid records
client.post("/operating-system-setup/add-os", data={ "os_name": "" })
client.post("/operating-system-setup/add-os")
client.post("/operating-system-setup/add-os", data={ "os_name": "Test-ing!£" })
client.post("/operating-system-setup/add-os", data={ "os_name": "Windows 11" })
with autotest_ui.app.app_context():
with client.session_transaction() as session:
flashed_messages = flask.get_flashed_messages(with_categories=True)
error_messages = [msg for cat, msg in flashed_messages if cat == "error"]
assert len(error_messages) == 4, f"Expected 4 errors. Errors found: {error_messages}"
with autotest_ui.app.app_context():
inserted_os = autotest_ui.OperatingSystem.query.all()
assert len(inserted_os) == 7
class Test_projects:
def test_project(self, client):
init_database(client)
login_as_admin(client)
response = client.get("/projects")
assert response.status_code == 200
client.post("/projects/add-project", data={
"project_name": "DirectPCI"
})
with autotest_ui.app.app_context():
inserted_project = autotest_ui.Project.query.all()
assert len(inserted_project) == 1
client.post("/projects/add-version", data={
"project_id": inserted_project[0].project_id,
"major": 1,
"minor": 2,
"micro": 0
})
client.post("/projects/add-version", data={
"project_id": inserted_project[0].project_id,
"major": 1,
"minor": 3,
"micro": 0
})
with autotest_ui.app.app_context():
inserted_project_version = autotest_ui.ProjectVersion.query.all()
assert len(inserted_project_version) == 2
+7
View File
@@ -0,0 +1,7 @@
VALID = True
INVALID = False
from . import defaults
from . import sanitise
from . import validation
+5
View File
@@ -0,0 +1,5 @@
import datetime
def unix_epoch():
return datetime.datetime(1970, 1, 1)
+21
View File
@@ -0,0 +1,21 @@
def mac_addr(addr):
WHITELIST_CHARS = ":0123456789ABCDEF"
addr = addr.upper().strip()
addr = "".join(char for char in addr if char in WHITELIST_CHARS)
return addr
def text_input(text, OVERWRITE_WHITELIST_CHARS=""):
WHITELIST_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _.-"
if OVERWRITE_WHITELIST_CHARS:
WHITELIST_CHARS = OVERWRITE_WHITELIST_CHARS
if not isinstance(text, str):
return ""
return "".join([char for char in text if char in WHITELIST_CHARS])
+65
View File
@@ -0,0 +1,65 @@
from . import VALID, INVALID
def mac_addr(addr):
VALID_MAC_CHARS = "0123456789abcdefABCDEF"
if len(addr) != 17:
return INVALID
COLON_START_IDX = 2
COLON_STEP = 3
for idx in range(COLON_START_IDX, len(addr), COLON_STEP):
if addr[idx] != ":":
return INVALID
mac_sections = addr.split(":")
if len(mac_sections) != 6:
return INVALID
for mac_byte in mac_sections:
if len(mac_byte) != 2:
return INVALID
for char in mac_byte:
if not char in VALID_MAC_CHARS:
return INVALID
return VALID
def password_strength(password):
PW_MIN_LEN = 8
if not isinstance(password, str):
return "Not a string"
if len(password) < PW_MIN_LEN:
return f"Password too short. {PW_MIN_LEN} character minimum"
def text_numeric_only(text, extra_chars=""):
WHITELIST_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
for char in text:
if not (char in WHITELIST_CHARS or char in extra_chars):
return INVALID
return VALID
def username(username):
return text_numeric_only(username, extra_chars=" _-")
def email(email):
return text_numeric_only(email, extra_chars=" _-@+.")