commit 0bf030f3c7ee759a48d104e5a52124458f31b256 Author: Jack Clifton Date: Sat Jun 27 12:21:37 2026 +0100 commit for publication diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae833b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +instance/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..aac77b1 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +Start development web server +$ git clone +$ 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/ + diff --git a/autotest_ui.Dockerfile b/autotest_ui.Dockerfile new file mode 100644 index 0000000..596e92b --- /dev/null +++ b/autotest_ui.Dockerfile @@ -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"] \ No newline at end of file diff --git a/autotest_ui.py b/autotest_ui.py new file mode 100644 index 0000000..ab6dde4 --- /dev/null +++ b/autotest_ui.py @@ -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/", 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/", 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/", 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/", 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/") +@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/", 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/", 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) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..07bcdc3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +Flask +Flask-SQLAlchemy +Flask-Login +SQLAlchemy-Utils +cryptography +requests +pymysql +gunicorn +pytest diff --git a/static/colour-default.css b/static/colour-default.css new file mode 100644 index 0000000..e0e2578 --- /dev/null +++ b/static/colour-default.css @@ -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; +} \ No newline at end of file diff --git a/static/layout.css b/static/layout.css new file mode 100644 index 0000000..55afefe --- /dev/null +++ b/static/layout.css @@ -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; +} + + + diff --git a/static/utils/clickable.js b/static/utils/clickable.js new file mode 100644 index 0000000..86ddab0 --- /dev/null +++ b/static/utils/clickable.js @@ -0,0 +1,13 @@ + + + +document.addEventListener("DOMContentLoaded", () => { + + const rows = document.querySelectorAll(".clickable"); + + rows.forEach(row => { + row.addEventListener("click", () => { + window.location.href = row.dataset.href; + }) + }) +}) \ No newline at end of file diff --git a/static/utils/validation.js b/static/utils/validation.js new file mode 100644 index 0000000..a3a04ca --- /dev/null +++ b/static/utils/validation.js @@ -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; + + } + +} + + diff --git a/templates/account-new.html b/templates/account-new.html new file mode 100644 index 0000000..a788d2e --- /dev/null +++ b/templates/account-new.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} + +{% block body %} + + +{% if current_user.user_role >= UserRoles.ADMIN %} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+{% endif %} + +{% endblock %} + diff --git a/templates/account.html b/templates/account.html new file mode 100644 index 0000000..35fb3be --- /dev/null +++ b/templates/account.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} + +{% block body %} + + +
+

My Account

+ + + + + + + + + + + + + +
First Name:{{ current_user.username }}
Email:{{ current_user.email }}
User Type:{{ current_user.user_role.text }}
+
+ + +{% if current_user.user_role >= UserRoles.ADMIN %} +
+ + + + + + + + + + + + + {% for user in users %} + + + +
+ +
+ + + + + + {% if current_user.user_role >= UserRoles.ADMIN %} + + + {% else %} + + {% endif %} + + + + {% endfor %} + +
IDNameEmailUser TypeSave
{{ user.id }}{{ user.username }}{{ user.email }} + + {{ user.usertype.type }}
+
+{% endif %} + +{% endblock %} + diff --git a/templates/active-tests-products.html b/templates/active-tests-products.html new file mode 100644 index 0000000..a04054d --- /dev/null +++ b/templates/active-tests-products.html @@ -0,0 +1,102 @@ +{% extends "base.html" %} +{% block title %}Test Bench Setup - Autotest{% endblock %} + +{% block body %} +
+ + +
+

Active Tests - {{ report_id }}

+ + + + + + + + + + + + + + + + + + + + + + {% for board in test_products %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
IDAuthorProjectVersionBoardOSDateStart TimeEnd TimePassedFailedDelete
+ {{ board.test_product_id }} + + {{ board.report.author.username }} + + {{ board.report.project_version.project.project_name }} + + {{ board.report.project_version.version_string }} + + {{ board.testboard.board.family.board_family_prefix }}{{ board.testboard.board.board_name }} + + {{ board.testboard.os.os_name }} + + {{ board.timestamp_start.strftime("%d %b %Y") }} + + {{ board.timestamp_start.strftime("%H:%M:%S") }} + + {{ board.timestamp_end.strftime("%H:%M:%S") }} + + 0 + + 0 + +
+ +
+
No tests are in progress.
+
+
+{% endblock %} + + + + +{% block scripts %} +{% endblock %} \ No newline at end of file diff --git a/templates/active-tests.html b/templates/active-tests.html new file mode 100644 index 0000000..bd4a0c1 --- /dev/null +++ b/templates/active-tests.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block title %}Test Bench Setup - Autotest{% endblock %} + +{% block body %} +
+ + +
+

Active Tests

+ + + + + + + + + + + + + + + {% for report in active_reports %} + + + + + + + + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
IDAuthorProjectVersionDateStart TimeStatusDelete
+ {{ report.test_report_id }} + + {{ report.author.username }} + + {{ report.project_version.project.project_name }} + + {{ report.project_version.version_string }} + + {{ report.timestamp_start.strftime("%d %b %Y") }} + + {{ report.timestamp_start.strftime("%H:%M:%S") }} + + In Progress + +
+ +
+
No tests are in progress.
+
+
+{% endblock %} + + + + +{% block scripts %} + + + +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..abd59bf --- /dev/null +++ b/templates/base.html @@ -0,0 +1,94 @@ + + + + + + {% block title %}Autotest{% endblock %} + + + + + + +
+

Autotest

+ {% if current_user.is_authenticated %} + + {% endif %} + + + +
+ + +
+ {% block body %}{% endblock %} +
+ + +
+ +
+ + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} + +
+ {% endfor %} + {% endif %} + {% endwith %} +
+ + + + + + + + {% block scripts %}{% endblock %} + + + + \ No newline at end of file diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..1456613 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + + +{% block body %} + +

+ testing home page +

+ +{% endblock %} \ No newline at end of file diff --git a/templates/operating_system_setup.html b/templates/operating_system_setup.html new file mode 100644 index 0000000..56235bb --- /dev/null +++ b/templates/operating_system_setup.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Operating System Setup - Autotest{% endblock %} + +{% block body %} +
+ +
+ +
+

Add Operating System

+ +
+
+ +
+

Operating Systems

+ + + + + + + + + {% for os in operating_systems %} + + + + + + + + {% else %} + + + + {% endfor %} + +
Operating SystemDelete
+ {{ os.os_name }} + +
+ +
+
No products registered yet. Add one on the left.
+
+
+{% endblock %} diff --git a/templates/product_setup.html b/templates/product_setup.html new file mode 100644 index 0000000..3ade6d6 --- /dev/null +++ b/templates/product_setup.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% block title %}Product Setup - Autotest{% endblock %} + +{% block body %} +
+ +
+ +
+

Add Board Family

+ +
+ +
+

Add New Board

+ +
+
+ +
+

Products

+ + + + + + + + + {% for boards in products_table %} + {% for board in boards %} + + + + + + + + {% endfor %} + {% else %} + + + + {% endfor %} + +
Product FamiliesBoards
+ {% if loop.first %}{{ board.family.board_family_prefix }}{% endif %} + + {{ board.family.board_family_prefix }}{{ board.board_name}} +
No products registered yet. Add one on the left.
+
+
+{% endblock %} diff --git a/templates/projects.html b/templates/projects.html new file mode 100644 index 0000000..8ee92ff --- /dev/null +++ b/templates/projects.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} + +{% block body %} + +
+

Create New Project

+ +
+ +
+

Create New Version

+ +
+ +
+

Project Registry

+ +{% for project in projects %} +
+

+ {{ project.project_name }} +

+ + + + + + + + + + + + {% for version in project.versions | sort(attribute='project_version_major', reverse=True) %} + + + + + + {% else %} + + + + {% endfor %} + +
VersionStatusVisibility
+ v{{ version.project_version_major }}.{{ version.project_version_minor }}.{{ version.project_version_micro }} + + {% if version.is_hidden %} + HIDDEN + {% else %} + ACTIVE + {% endif %} + + +
+
+ + +
+
+
No semantic versions published for this asset profile.
+
+{% else %} +

+ No tracked projects found in database engine. Use creation module to register systems. +

+{% endfor %} + +{% endblock %} diff --git a/templates/schedule_new_test.html b/templates/schedule_new_test.html new file mode 100644 index 0000000..c58c81d --- /dev/null +++ b/templates/schedule_new_test.html @@ -0,0 +1,146 @@ +{% extends "base.html" %} +{% block title %}Test Bench Setup - Autotest{% endblock %} + +{% block body %} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + + + + + + + {% for board in test_boards %} + + + + + + + {% else %} + + + + {% endfor %} + +
Board NameOperating SystemMAC Address
{{ board.board.family.board_family_prefix }}{{ board.board.board_name }}{{ board.os.os_name }}{{ board.mac_addr }}
No board have been added to the test bench.
+ +
+ + + + +
+ +
+{% endblock %} + + + + +{% block scripts %} + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/signin.html b/templates/signin.html new file mode 100644 index 0000000..5003f88 --- /dev/null +++ b/templates/signin.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block body %} + +
+

Sign in

+ +
+ +{% endblock %} diff --git a/templates/test_bench.html b/templates/test_bench.html new file mode 100644 index 0000000..1005604 --- /dev/null +++ b/templates/test_bench.html @@ -0,0 +1,115 @@ +{% extends "base.html" %} +{% block title %}Test Bench Setup - Autotest{% endblock %} + +{% block body %} +
+ +
+ +
+

Add Board

+ +
+
+ +
+

Test Units

+ + + + + + + + + + + {% for test_unit in test_boards %} + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
BoardMAC AddressOSDelete
+ {{ test_unit.board.family.board_family_prefix }}{{ test_unit.board.board_name }} + + {{ test_unit.mac_addr }} + + +
+ +
+
+
+ +
+
No products registered yet. Add one on the left.
+
+
+{% endblock %} + + + + +{% block scripts %} + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/test_pytest_run.py b/test_pytest_run.py new file mode 100644 index 0000000..d278a06 --- /dev/null +++ b/test_pytest_run.py @@ -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 + + diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..c2a7db8 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,7 @@ + +VALID = True +INVALID = False + +from . import defaults +from . import sanitise +from . import validation \ No newline at end of file diff --git a/utils/defaults.py b/utils/defaults.py new file mode 100644 index 0000000..9c0e97b --- /dev/null +++ b/utils/defaults.py @@ -0,0 +1,5 @@ + +import datetime + +def unix_epoch(): + return datetime.datetime(1970, 1, 1) \ No newline at end of file diff --git a/utils/sanitise.py b/utils/sanitise.py new file mode 100644 index 0000000..6b4638e --- /dev/null +++ b/utils/sanitise.py @@ -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]) + + + diff --git a/utils/validation.py b/utils/validation.py new file mode 100644 index 0000000..4442ba2 --- /dev/null +++ b/utils/validation.py @@ -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=" _-@+.") + + +