diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml new file mode 100644 index 0000000..645daa2 --- /dev/null +++ b/.github/workflows/pylint.yml @@ -0,0 +1,26 @@ +name: Pylint + +on: [ push ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ "3.10" ] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Analysing the code with pylint + run: | + pylint app --disable=C0116,C0114,C0115,C0411,E0401,W0611,W0622,W0719,C0103,W1514,R0903,R1732,W0718 + - name: Analysing the code with pycodestyle + run: | + pycodestyle app diff --git a/.gitignore b/.gitignore index 3e46e49..c615a4c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Project-specific files -config.cfg +external/ +log.txt + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -151,4 +153,6 @@ out/ .vscode/ .fleet +.DS_Store +**/.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..650f0b1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.10-slim + +WORKDIR /app + +COPY requirements.txt /app +RUN --mount=type=cache,target=/root/.cache/pip \ + pip3 install -r requirements.txt + +COPY . /app + +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "application:app"] \ No newline at end of file diff --git a/README.md b/README.md index bd545a7..39781a6 100644 --- a/README.md +++ b/README.md @@ -3,122 +3,125 @@ PRISMO Admin Panel =================== -The goal of this webtool is provide basic management capabilities for hackerspaces, like: +Prismo is fully open source and easy to install access system for control access of tools and equipment for maker +spaces. -1. Presence management basing on MAC address monitoring -2. RFID access system management -3. Payments monitoring -4. Internal information storage(wiki based) +The gold for the project to create a system which any maker space in the world can setup for own use. The system fully +open source, include the backend, readers firmware and PCB schema. -## Prepare database +## Installation by docker -Install docker on your system. +- Install docker on the host machine. + Check [the tutorial for Raspberry Pi 4](https://github.com/codingforentrepreneurs/Pi-Awesome/blob/main/how-tos/Docker%20%26%20Docker%20Compose%20on%20Raspberry%20Pi.md) +- Create a folder `data` - the folder use for keep all persistent data, like a database. +- Run docker container: -1. Pull PostgreSQL docker image +```bash +docker run --name=prismo-app -p 80:5000 --restart always --detach -v "$(pwd)/data/:/app/external/" hacklabkyiv/prismo-app:0.1.7 +``` + +Add docker to autostart: - ```bash - $ docker pull postgres - ``` +```bash +sudo systemctl enable docker +``` -2. Add user to group docker, user this instructions https://docs.docker.com/install/linux/linux-postinstall/. This will allow to use docker without sudo. TODO: update step for MacOS users +The application ready to work and available on `http://localhost:5000` -#### Optional steps +### The reader firmware -By default, this should be run by Prismo admin process, but for debugging purpose you should run this commands by yourself. +The reader is a device which connected to the network and read RFID cards. The reader firmware is stored in +the `prismo-reader` [repository](https://github.com/hacklabkyiv/prismo-reader/tree/micropython_pn532). -1. Run docker with. Here we will create database with name `prismo-db` inside docker container. +### Configuration - ```bash - $ docker run -d --name prismo-db -e POSTGRES_PASSWORD=12345678 -e POSTGRES_DB=visitors -e POSTGRES_USER=prismo -p 5432:5432 -v $(pwd)/data:/var/lib/postgresql/data postgres - ``` +Config file name is `config.cfg`, the file located in the root directory of the project. Configs stored in YAML format. -2. Let's connect to database +``` +logging: + logfile: log.txt + logsize_kb: 1000 + rolldepth: 3 +``` - ```bash - $ $ docker exec -it prismo-db psql -h localhost -U prismo -d visitors - psql (12.2 (Debian 12.2-2.pgdg100+1)) - Type "help" for help. - - visitors=# - ``` +## Development -3. Now you are in SQL console, basic commands are +### Preconditions - ``` - \? # Get help - \d # Describe table - \q # Quit psql - ``` +- Python 3.10+ with pip +- git +- supervisor(optional) -4. Let's create table with users. Also we will create two columns with access to door and lathe. +### Step-by-step installation - ```bash - visitors=# CREATE TABLE users ( id serial primary key, name text, key text, last_enter timestamp, door boolean, lathe boolean); - ``` - ```bash - visitors=# CREATE TABLE logs(device_name text, key text, time integer); - ``` - Alternatively you can add column to already existed table by command: `ALTER TABLE users ADD COLUMN last_enter TIMESTAMP;` +1. Clone the repository: -5. Show contents of table: + ```sh + git clone git@github.com:hacklabkyiv/prismo.git + ``` + or by https: + ```sh + git clone https://github.com/hacklabkyiv/prismo.git + ``` - ```bash - # SELECT * FROM users; - id | name | key | door | lathe - ----+------+-----+------+------- - (0 rows) - ``` +2. Install virtualenv in project's directory: -6. Quit database with `\q` + ```sh + $ python3 -m venv ./virtualenv + ``` -If you want to stop docker container just run `docker stop prismo-db`, to start it again use `docker start prismo-db` +3. Activate virtual environment -## Installation + ``` + source ./virtualenv/bin/activate + ``` -1. Install virtualenv in project's directory: - ```sh - $ python3 -m venv ./virtualenv - ``` - -2. Activate virtual environment - - ``` - source ./virtualenv/bin/activate - ``` - -3. Install required packages: - - ```sh - $ pip3 install -r requirements.txt - ``` - -4. Run app: - ```sh - $ export FLASK_APP=application.py - $ flask run - ``` - table.sql contains create statements for database tables - -Configuration -============= - -Currently config is stored in YAML file. Example of config: +4. Install required packages: + ```sh + pip3 install -r requirements.txt + ``` + +5. Run for debugging and development: (it will reload app on code changes and enable debug mode) + +```sh +export FLASK_APP=application.py +flask run --debug ``` -# Example config file -data: - user: prismo - password: 12345678 - host: localhost - port: 5432 - name: visitors - latest-key-file: ./key.txt -logging: - debug: Yes - logfile: log.txt - logsize_kb: 1000 - rolldepth: 3 -``` -path to config file is set in `applicaiton.py`. By default, config file name is `config.cfg` +By default, this should be run by Prismo admin process, but for debugging purpose you should run this commands by +yourself. + +## Database + +All information about the database is stored in [doc/database.md](docs/database.md) file. + +### Logging + +All logs are stored in `log.txt` file. + +## API + +The docs for API is stored in [docs/api.md](docs/api.md) file. +## Slack + +Slack integration works with slack bot. You need to create slack bot in your slack workspace and get token for it. +Scope: + +- chat:write +- files:write +- incoming-webhook + +## Build docker image + +The main target platform is `linux/arm64/v8` (Raspberry Pi 4). To build docker image for this platform you should use +buildx. + +Execute `docker login` with hacklabkyiv credentials. +Execute this commands in the root directory of the project: + +``` +docker buildx create --use +docker buildx build --platform linux/arm64/v8 -t hacklabkyiv/prismo-app:<version> --push . +``` diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..97b9e6c --- /dev/null +++ b/app/config.py @@ -0,0 +1,65 @@ +import json +import logging +import os +import sys +import secrets +from pathlib import Path + +import yaml + +try: + from yaml import CLoader as Loader, CDumper +except ImportError: + from yaml import Loader + +# Configuration file +CONFIG_FILE = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../config.cfg') + +# Initial setup +try: + cfg = yaml.load(open(CONFIG_FILE, 'r'), Loader=Loader) +except IOError as e: + logging.error("Config file not found!") + logging.error("Exception: %s", str(e)) + sys.exit(1) + +os.makedirs("external", exist_ok=True) +database_file = Path("external/database.db") + +UPLOAD_FOLDER = '/uploads' + +internal_config_file = Path("external/internal_config.json") +slat_key = 'slat' +key_slack_token = 'key_slack_token' +key_slack_backup_channel = 'key_slack_backup_channel' +key_secret_key = 'key_secret_key' +key_database_version = 'key_database_version' + + +def get_setting(key: str): + with open(internal_config_file, 'r') as config_file: + config = json.load(config_file) + config_file.close() + return config.get(key, None) + + +def set_setting(key: str, value: str): + with open(internal_config_file, 'r') as config_file: + config = json.load(config_file) + config_file.close() + + config[key] = value + + with open(internal_config_file, 'w') as config_file: + json.dump(config, config_file, indent=4) + config_file.close() + + +def create_internal_config_file(): + if not internal_config_file.is_file(): + with open(internal_config_file, 'w') as config_file: + json.dump({ + key_database_version: 1, + key_secret_key: secrets.token_hex(32), + }, config_file, indent=4) + config_file.close() diff --git a/app/data/device_dto.py b/app/data/device_dto.py new file mode 100644 index 0000000..c18e3f1 --- /dev/null +++ b/app/data/device_dto.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@dataclass +class DeviceDto: + id: str + name: str + + def __init__(self, id: str, name: str): + self.id = id + self.name = name diff --git a/app/data/dtos.py b/app/data/dtos.py new file mode 100644 index 0000000..fc457d6 --- /dev/null +++ b/app/data/dtos.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + + +@dataclass +class UserDto: + key: str + name: str + + def __init__(self, user_key, user_name): + self.key = user_key + self.name = user_name + + +@dataclass +class OperationDto: + time: int + type: str + + def __init__(self, operation_time, operation_type): + self.time = operation_time + self.type = operation_type diff --git a/app/data/work_logs_repository.py b/app/data/work_logs_repository.py new file mode 100644 index 0000000..0124ac4 --- /dev/null +++ b/app/data/work_logs_repository.py @@ -0,0 +1,84 @@ +from sqlite3 import Row + +from app.features.admin.init_app import get_db_connection + + +def get_latest_key() -> str | None: + """ + Get last triggered key, to add new users by clicking on any reader + """ + connection = get_db_connection() + rows = ( + connection.cursor() + .execute( + "SELECT user_key " + "FROM event_logs " + "WHERE user_key IS NOT NULL AND operation_type = 'deny_access' " + "ORDER BY operation_time DESC LIMIT 1") + .fetchone() + ) + + if rows is None: + return None + + return rows[0] + + +def query_event_logs(start_time=None, end_time=None, limit=100, offset=0): + """ + Retrieve event logs from a SQLite database within a specified time range and limit the number + of results. + + Args: + start_time (str, optional): The start time of the time range to filter the logs. Should +be in the format 'YYYY-MM-DD HH:MM:SS'. + end_time (str, optional): The end time of the time range to filter the logs. Should be +in the format 'YYYY-MM-DD HH:MM:SS'. + limit (int, optional): The maximum number of log entries to retrieve. Default is 100. + offset (int, optional): The offset from the beginning of the log entries to start +retrieving results. Default is 0. + + Returns: + list of dict: A list of dictionaries representing the retrieved log entries. + Each dictionary contains the following keys: + - 'name' (str): Username. + - 'key' (str): User key. + - 'device_name' (str): Device(Reader) name. + - 'device_id' (int): Device(Reader) ID. + - 'operation_type' (str): Type of operation. + - 'operation_time' (str): Time of the operation in 'YYYY-MM-DD HH:MM:SS' format. + + Example: + Retrieve logs for a specific time range and limit the results + + logs = query_event_logs(start_time='2023-01-01 00:00:00', end_time='2023-01-31 23:59:59', + limit=50) + """ + connection = get_db_connection() + connection.row_factory = Row + cursor = connection.cursor() + + query = """ + SELECT u.name, u.key, d.name, d.id, operation_type, operation_time + FROM event_logs + LEFT JOIN users u ON event_logs.user_key = u.key + LEFT JOIN devices d ON d.id = event_logs.device_id + """ + + if start_time is not None and end_time is not None: + query += "WHERE operation_time >= ? AND operation_time <= ?" + cursor.execute(query + " ORDER BY operation_time DESC LIMIT ? OFFSET ?", + (start_time, end_time, limit, offset)) + else: + query += "ORDER BY operation_time DESC LIMIT ? OFFSET ?" + cursor.execute(query, (limit, offset)) + + results = cursor.fetchall() + + # Convert the results to a list of dictionaries + result_dicts = [dict(row) for row in results] + + # Don't forget to close the cursor and the connection when done + cursor.close() + + return result_dicts diff --git a/app/features/admin/admin_routrers.py b/app/features/admin/admin_routrers.py new file mode 100644 index 0000000..77edf8c --- /dev/null +++ b/app/features/admin/admin_routrers.py @@ -0,0 +1,48 @@ +import flask +import flask_login +from flask import Blueprint, render_template, request + +from app.features.admin.admins_repository import get_flask_admin_user_by_credentials +from app.features.admin.init_app import init_app + +admin_blue_print = Blueprint('admin', __name__) + + +@admin_blue_print.route('/init_app', methods=['GET', 'POST']) +def init_app_route(): + if request.method == 'GET': + return render_template('init_app.html') + + username = request.form['username'] + password = request.form['password'] + slat = flask.request.form['slat'] + if 'file' in flask.request.files: + file = request.files['file'] + else: + file = None + + init_app(username, password, slat, file) + + return flask.redirect(flask.url_for('admin.login')) + + +@admin_blue_print.route('/login', methods=['GET', 'POST']) +def login(): + if flask.request.method == 'GET': + return render_template('login.html') + + username = flask.request.form['username'] + password = flask.request.form['password'] + flask_admin_user = get_flask_admin_user_by_credentials(username, password) + + if flask_admin_user is None: + return "Bad login" + + flask_login.login_user(flask_admin_user) + return flask.redirect(flask.url_for('access_panel')) + + +@admin_blue_print.route('/logout') +def logout(): + flask_login.logout_user() + return flask.redirect(flask.url_for('admin.login')) diff --git a/app/features/admin/admins_repository.py b/app/features/admin/admins_repository.py new file mode 100644 index 0000000..aca8d24 --- /dev/null +++ b/app/features/admin/admins_repository.py @@ -0,0 +1,92 @@ +# pylint: disable=attribute-defined-outside-init +import flask_login + +from app.features.admin.init_app import get_db_connection +from app.features.admin.password import hash_password + + +class FlaskAdminUser(flask_login.UserMixin): + pass + + +class AdminUser: + id: int + username: str + password: str + + def __init__(self, id, username, password): + self.id = id + self.username = username + self.password = password + + +def get_flask_admin_user_by_credentials(username: str, password: str) -> FlaskAdminUser | None: + connection = get_db_connection() + rows = connection.execute( + "SELECT id, password FROM admins WHERE username=?", (username,) + ).fetchall() + + if len(rows) == 0: + return None + + db_admin_id, db_password = rows[0] + if db_password == hash_password(password): + user = FlaskAdminUser() + user.id = db_admin_id + return user + + return None + + +def get_flask_admin_user_by_id(user_id: str) -> FlaskAdminUser | None: + user = get_admin_user_by_id(user_id) + if user is None: + return None + + flask_user = FlaskAdminUser() + flask_user.id = user.id + return flask_user + + +def get_flask_admin_user_by_user_name(user_name: str) -> FlaskAdminUser | None: + user = get_admin_user_by_user_name(user_name) + if user is None: + return None + + flask_user = FlaskAdminUser() + flask_user.id = user.id + return flask_user + + +def get_admin_user_by_flask_user(flask_user) -> AdminUser | None: + if flask_user.is_anonymous: + return None + return get_admin_user_by_id(flask_user.id) + + +def get_admin_user_by_id(user_id: str) -> AdminUser | None: + connection = get_db_connection() + rows = connection.execute( + "SELECT id, username, password FROM admins WHERE id=?", (user_id,) + ).fetchall() + + if len(rows) == 0: + return None + + admin_id, username, password = rows[0] + user = AdminUser(admin_id, username, password) + return user + + +def get_admin_user_by_user_name(user_name: str) -> AdminUser | None: + connection = get_db_connection() + rows = connection.execute( + "SELECT id, username, password FROM admins WHERE username=?", (user_name,) + ).fetchall() + + if len(rows) == 0: + return None + + admin_id, username, password = rows[0] + user = AdminUser(admin_id, username, password) + return user diff --git a/app/features/admin/init_app.py b/app/features/admin/init_app.py new file mode 100644 index 0000000..c48841b --- /dev/null +++ b/app/features/admin/init_app.py @@ -0,0 +1,105 @@ +import os +import sqlite3 + +from werkzeug.utils import secure_filename + +from app.config import database_file, internal_config_file, slat_key, set_setting +from app.features.admin.password import hash_password + +_database_connection = None + + +def is_app_inited(): + return database_file.is_file() and internal_config_file.is_file() + + +def init_app(admin_username: str, admin_password: str, slat: str, file): + if is_app_inited(): + raise Exception("App already initialized") + + set_setting(slat_key, slat) + + if (file is not None) and (not file.filename == ''): + init_database_from_backup(admin_username, admin_password, file) + else: + init_database(admin_username, admin_password) + + +def init_database_from_backup(admin_username: str, admin_password: str, file): + if file.filename == '': + raise Exception("Invalid file name") + + filename = secure_filename(file.filename) + if not filename.endswith(".db"): + raise Exception("Invalid file extension") + file.save(database_file) + + verify_is_secrets_from_backup(admin_password, admin_username) + + +def verify_is_secrets_from_backup(admin_password, admin_username): + cursor = get_db_connection().cursor() + rows = cursor.execute( + "SELECT * FROM admins where username = ? and password= ?", + (admin_username, hash_password(admin_password)) + ).fetchone() + if (rows is None) or (len(rows) == 0): + os.remove(database_file) + raise Exception("Invalid admin credentials") + + +def init_database(admin_username: str, admin_password: str): + sqlite3.connect(database_file) + connection = get_db_connection() + connection.executescript(""" + CREATE TABLE admins( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + password TEXT NOT NULL + ); + + CREATE TABLE users( + name TEXT NOT NULL, + key TEXT NOT NULL, + slack_id TEXT DEFAULT NULL + ); + + CREATE TABLE devices( + id TEXT NOT NULL, + name TEXT NOT NULL, + slack_channel_id TEXT DEFAULT NULL + ); + + CREATE TABLE permissions( + device_id TEXT NOT NULL, + user_key TEXT NOT NULL + ); + + CREATE TABLE event_logs( + device_id TEXT NOT NULL, + user_key TEXT, + operation_type TEXT NOT NULL, + operation_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + """) + connection.commit() + + connection = get_db_connection() + connection.execute( + "INSERT INTO admins(username, password) VALUES (?, ?)", + (admin_username, hash_password(admin_password)) + ) + connection.commit() + + +def get_db_connection(): + # pylint: disable=global-statement + if not is_app_inited(): + return None + + global _database_connection + + if not _database_connection: + _database_connection = sqlite3.connect(database_file, check_same_thread=False) + _database_connection.row_factory = sqlite3.Row + return _database_connection diff --git a/app/features/admin/password.py b/app/features/admin/password.py new file mode 100644 index 0000000..e5f0e91 --- /dev/null +++ b/app/features/admin/password.py @@ -0,0 +1,9 @@ +import hashlib + +from app.config import get_setting, slat_key + + +def hash_password(password: str): + final_slat = get_setting(slat_key) + str(len(password)) + hashed_pass = hashlib.sha256((password + final_slat).encode('utf-8')).hexdigest() + return hashed_pass diff --git a/app/features/backup_database.py b/app/features/backup_database.py new file mode 100644 index 0000000..e51c34c --- /dev/null +++ b/app/features/backup_database.py @@ -0,0 +1,24 @@ +import logging + +from slack_sdk import WebClient + +from app.config import get_setting, key_slack_token, key_slack_backup_channel, database_file + + +def backup_data_base(): + slack_token = get_setting(key_slack_token) + channel_id = get_setting(key_slack_backup_channel) + + if (slack_token is None) or (channel_id is None): + return + + client = WebClient(token=slack_token) + + response = client.files_upload_v2( + channel=channel_id, + file=open(database_file, "rb").read(), + title="Database backup", + initial_comment="Here is the latest version of the database.", + ) + + logging.info(response) diff --git a/app/features/permissions/access_pannel.py b/app/features/permissions/access_pannel.py new file mode 100644 index 0000000..0fd4875 --- /dev/null +++ b/app/features/permissions/access_pannel.py @@ -0,0 +1,66 @@ +from dataclasses import dataclass +from typing import List + +from app.features.readers.device_repository import get_all_devices +from app.data.dtos import UserDto +from app.features.users.user_repository import get_all_users +from app.features.permissions.permissions_repository import get_user_permissions + + +@dataclass +class PermissionUiModel: + is_granted: bool + device_key: str + + def __init__(self, is_granted, device_key): + self.is_granted = is_granted + self.device_key = device_key + + +@dataclass +class AccessControlPanelRow: + user_name: str + user_key: str + permissions: List[PermissionUiModel] + + def __init__(self, user_name, user_key, permissions): + self.user_name = user_name + self.user_key = user_key + self.permissions = permissions + + +@dataclass +class AccessControlPanel: + header: List[str] + rows: List[AccessControlPanelRow] + + def __init__(self, header, rows): + self.header = header + self.rows = rows + + +def get_access_control_panel() -> AccessControlPanel: + users = get_all_users() + devices = get_all_devices() + + header = ['User name', 'User key'] + for device in devices: + header.append(device.name) + + rows = [] + + for user in users: + access_control_panel_row = build_user_access_control_permissions_row(user, devices) + rows.append(access_control_panel_row) + + return AccessControlPanel(header, rows) + + +def build_user_access_control_permissions_row(user: UserDto, devices): + grated_user_device = get_user_permissions(user.key) + user_permissions_ui_models = [] + for device in devices: + permission_model = PermissionUiModel(device.id in grated_user_device, device.id) + user_permissions_ui_models.append(permission_model) + + return AccessControlPanelRow(user.name, user.key, user_permissions_ui_models) diff --git a/app/features/permissions/permission_routers.py b/app/features/permissions/permission_routers.py new file mode 100644 index 0000000..ae65599 --- /dev/null +++ b/app/features/permissions/permission_routers.py @@ -0,0 +1,24 @@ +import flask_login +from flask import Blueprint, request + +from app.features.permissions.permissions_repository import grant_permission, reject_permission + +permissions_blue_print = Blueprint('permission', __name__, url_prefix='/permission') + + +@permissions_blue_print.route('', methods=['POST']) +@flask_login.login_required +def grant_permission_route(): + user_key = request.form['user_key'] + permission = request.form['device_id'] + grant_permission(user_key, permission) + return 'OK' + + +@permissions_blue_print.route('', methods=['DELETE']) +@flask_login.login_required +def reject_permission_route(): + user_key = request.form['user_key'] + permission = request.form['device_id'] + reject_permission(user_key, permission) + return 'OK' diff --git a/app/features/permissions/permissions_repository.py b/app/features/permissions/permissions_repository.py new file mode 100644 index 0000000..a8636a1 --- /dev/null +++ b/app/features/permissions/permissions_repository.py @@ -0,0 +1,61 @@ +import logging +from dataclasses import dataclass +from typing import List + +from app.features.admin.init_app import get_db_connection + + +@dataclass +class UserPermission: + user_key: str + permissions: List[str] + + def __init__(self, user_key, permissions): + self.user_key = user_key + self.permissions = permissions + + +def get_user_permissions(user_key) -> List[str]: + connection = get_db_connection() + rows = connection.cursor().execute( + "SELECT device_id FROM permissions WHERE user_key = ?", (user_key,) + ).fetchall() + + user_permissions = [] + for row in rows: + key, = row + user_permissions.append(key) + + logging.info('user with id %s, permissions: %s', user_key, user_permissions) + return user_permissions + + +def get_user_with_permission_to_device(device_id): + connection = get_db_connection() + rows = connection.cursor().execute( + "SELECT user_key FROM permissions WHERE device_id = ?", (device_id,) + ).fetchall() + users = [] + for row in rows: + key, = row + users.append(key) + + return users + + +def grant_permission(user_key, device_id): + connection = get_db_connection() + connection.cursor().execute( + "INSERT INTO permissions(user_key, device_id) VALUES (?, ?)", (user_key, device_id) + ) + logging.info('Grant permission for user with id %s to device %s', user_key, device_id) + connection.commit() + + +def reject_permission(user_key, device_id): + connection = get_db_connection() + connection.cursor().execute( + "delete from permissions where user_key=? and device_id=?", (user_key, device_id) + ) + connection.commit() + logging.info('Reject permission for user with id %s to device %s', user_key, device_id) diff --git a/app/features/readers/device_repository.py b/app/features/readers/device_repository.py new file mode 100644 index 0000000..ecd71bd --- /dev/null +++ b/app/features/readers/device_repository.py @@ -0,0 +1,75 @@ +import logging +from dataclasses import dataclass +from typing import List + +from app.data.device_dto import DeviceDto +from app.data.dtos import UserDto, OperationDto +from app.features.admin.init_app import get_db_connection + + +def get_all_devices() -> list[DeviceDto]: + connection = get_db_connection() + rows = connection.cursor().execute("SELECT id, name FROM devices order by name").fetchall() + + devices = [] + for row in rows: + device_id, device_name = row + device = DeviceDto(device_id, device_name) + devices.append(device) + + return devices + + +def get_full_device(device_id): + connection = get_db_connection() + row = connection.cursor().execute("SELECT id, name, slack_channel_id FROM devices WHERE id=?", + (device_id,)).fetchall() + + if len(row) == 0: + return None + + device_id, device_name, slack_channel_id = row[0] + + rows = connection.cursor().execute( + "SELECT u.key, u.name, operation_type, operation_time " + "FROM event_logs " + "join users u on u.key = user_key " + "WHERE device_id=? order by operation_time desc", (device_id,) + ).fetchall() + + logs = [] + for row in rows: + user_key, user_name, operation_type, operation_time = row + user = UserDto(user_key, user_name) + operation = OperationDto(operation_time, operation_type) + + logs.append({ + "user": user, + "operation": operation, + }) + + rows = connection.cursor().execute( + "SELECT u.key, u.name FROM permissions JOIN users u ON u.key = permissions.user_key WHERE " + "permissions.device_id=?", (device_id,) + ).fetchall() + user_with_access = [] + for row in rows: + user_key, user_name = row + user_with_access.append(UserDto(user_key, user_name)) + + return { + "device": { + "name": device_name, + "id": device_id, + "slack_channel_id": slack_channel_id, + }, + "logs": logs, + "user_with_access": user_with_access, + } + + +def add_device(device_id, device_name): + connection = get_db_connection() + connection.execute("INSERT INTO devices(id, name) VALUES(?,?)", (device_id, device_name)) + logging.info('Device added: %s, %s', device_id, device_name) + connection.commit() diff --git a/app/features/readers/manage_device.py b/app/features/readers/manage_device.py new file mode 100644 index 0000000..cb744ce --- /dev/null +++ b/app/features/readers/manage_device.py @@ -0,0 +1,52 @@ +import flask_login +from flask import Blueprint, render_template, request + +from app.features.admin.init_app import get_db_connection +from app.features.readers.device_repository import get_all_devices, get_full_device, add_device +from app.features.slack_notifier import send_channel_message + +manage_device_blue_print = Blueprint('manage_device', __name__) + + +@manage_device_blue_print.route('/devices') +def devices(): + return render_template('devices.html', devices=get_all_devices()) + + +@manage_device_blue_print.route("/device/<device_id>", methods=["GET", "POST"]) +def device_page(device_id): + if request.method == 'POST': + slack_channel_id = request.form['slack_channel_id'] + + connection = get_db_connection() + connection.execute( + "UPDATE devices SET slack_channel_id=? WHERE id=?", + (slack_channel_id, device_id) + ) + connection.commit() + return render_template("device_page.html", full_device=get_full_device(device_id)) + + +@manage_device_blue_print.route('/device', methods=['POST']) +@flask_login.login_required +def add_device_route(): + device_id = request.form['device_id'] + device_name = request.form['device_name'] + add_device(device_id, device_name) + return 'OK' + + +@manage_device_blue_print.route('/send-test-message-to-channel', methods=['POST']) +@flask_login.login_required +def send_test_message_to_dm(): + json = request.json + device_id = json['device_id'] + cursor = get_db_connection().cursor() + cursor.execute("SELECT slack_channel_id, name FROM devices WHERE id=?", (device_id,)) + slack_channel_id, name, = cursor.fetchall()[0] + if slack_channel_id is None: + return 'No slack channel id for device ' + device_id + + send_channel_message(slack_channel_id, "Test message for device " + name) + + return 'OK' diff --git a/app/features/readers/reader_routers.py b/app/features/readers/reader_routers.py new file mode 100644 index 0000000..2e861c4 --- /dev/null +++ b/app/features/readers/reader_routers.py @@ -0,0 +1,124 @@ +import logging + +from flask import Blueprint, request + +from app.features.admin.init_app import get_db_connection +from app.features.permissions.permissions_repository import ( + get_user_with_permission_to_device, +) +from app.features.slack_notifier import send_dm_message, send_channel_message + +import json + +reader_blue_print = Blueprint("reader", __name__, url_prefix="/reader") + + +@reader_blue_print.route("/<device_id>/accesses/", methods=["GET"]) +def accesses(device_id): + return {"keys": get_user_with_permission_to_device(device_id)} + + +@reader_blue_print.route("/<device_id>/log_operation", methods=["POST"]) +def log_operation(device_id): + json_data = json.loads(request.get_json()) + + logging.info("Received request: %s", str(json_data)) + + operation = json_data["operation"] + if operation not in ["lock", "unlock", "deny_access"]: + raise Exception("Invalid operation") + + try: + user_key = json_data["key"] + except KeyError: + user_key = None + + if (operation == "unlock") and user_key is None: + raise Exception("Invalid operation") + + if operation == "unlock": + send_log_of_last_usage(device_id, user_key) + send_message_of_unlocking(device_id, user_key) + + if operation == "lock": + send_message_of_locking(device_id) + + connection = get_db_connection() + connection.execute( + "INSERT INTO event_logs(device_id, user_key, operation_type) VALUES (?, ?, ?)", + (device_id, user_key, operation), + ) + connection.commit() + return "OK", 201 + + +def send_message_of_locking(device_id): + cursor = get_db_connection().cursor() + cursor.execute( + "SELECT slack_channel_id, name FROM devices WHERE id=?", (device_id,) + ) + try: + slack_channel_id, device_name, = cursor.fetchall()[0] + if slack_channel_id is None: + raise Exception("No slack channel id for device " + device_id) + + message = f"The {device_name} is free now" + + send_channel_message(slack_channel_id, message) + except Exception as err: + print("Cannot send slack message.") + print(f"Unexpected {err=}, {type(err)=}") + # raise + + +def send_message_of_unlocking(device_id, user_key): + cursor = get_db_connection().cursor() + cursor.execute( + "SELECT slack_channel_id, name FROM devices WHERE id=?", (device_id,) + ) + try: + slack_channel_id, device_name, = cursor.fetchall()[0] + if slack_channel_id is None: + raise Exception("No slack channel id for device " + device_id) + + user_cursor = get_db_connection().cursor() + user_cursor.execute("SELECT slack_id, name FROM users WHERE key=?", (user_key,)) + slack_id, user_name = user_cursor.fetchall()[0] + if slack_id is None: + message = f"{user_name} start using the {device_name}" + else: + message = f"<@{slack_id}> start using the {device_name}" + + send_channel_message(slack_channel_id, message) + except Exception as err: + print("Cannot send slack message.") + print(f"Unexpected {err=}, {type(err)=}") + # raise + + +def send_log_of_last_usage(device_id, user_key): + cursor = get_db_connection().cursor() + cursor.execute( + "SELECT slack_id, name, operation_time " + "FROM event_logs " + "JOIN users user ON event_logs.user_key = user.key " + "WHERE device_id = ? and operation_type = 'unlock' " + "ORDER BY operation_time DESC " + "LIMIT 3", + (device_id,), + ) + rows = cursor.fetchall() + message = "The last 3 people who unlocked the door were: \n" + try: + for row in rows: + slack_id, name, operation_time = row + if slack_id is None: + message += f"{name} at {operation_time}\n" + else: + message += f"<@{slack_id}> at {operation_time}\n" + + send_dm_message(user_key, message) + except Exception as err: + print("Cannot send slack message.") + print(f"Unexpected {err=}, {type(err)=}") + # raise diff --git a/app/features/slack_notifier.py b/app/features/slack_notifier.py new file mode 100644 index 0000000..1e5d4fd --- /dev/null +++ b/app/features/slack_notifier.py @@ -0,0 +1,44 @@ +import logging + +from slack_sdk import WebClient + +from app.config import get_setting, key_slack_token +from app.features.admin.init_app import get_db_connection + + +def send_dm_message(user_key, message): + slack_token = get_setting(key_slack_token) + connection = get_db_connection() + row = connection.cursor().execute("SELECT slack_id FROM users WHERE key=?", + (user_key,)).fetchall() + if len(row) != 1: + raise Exception('No user with such key') + slack_id = row[0][0] + print("slack id", slack_id) + + if (slack_token is None) or (slack_id is None): + return + + client = WebClient(token=slack_token) + + response = client.chat_postMessage( + channel=slack_id, + text=message, + ) + + logging.info(response) + + +def send_channel_message(channel_id, message): + slack_token = get_setting(key_slack_token) + if (slack_token is None) or (channel_id is None): + return + + client = WebClient(token=slack_token) + + response = client.chat_postMessage( + channel=channel_id, + text=message, + ) + + logging.info(response) diff --git a/app/features/users/user_repository.py b/app/features/users/user_repository.py new file mode 100644 index 0000000..fc9280c --- /dev/null +++ b/app/features/users/user_repository.py @@ -0,0 +1,85 @@ +import logging + +from app.data.device_dto import DeviceDto +from app.data.dtos import UserDto, OperationDto +from app.features.admin.init_app import get_db_connection + + +def get_full_user(user_key): + connection = get_db_connection() + row = connection.execute( + "SELECT key, name, slack_id FROM users WHERE key=?", (user_key,) + ).fetchall() + + if len(row) == 0: + return None + + user_key, user_name, slack_id = row[0] + + rows = connection.execute( + "SELECT d.id, d.name FROM permissions JOIN devices d ON d.id = permissions.device_id " + "WHERE permissions.user_key = ?", (user_key,) + ).fetchall() + + user_devices = [] + for row in rows: + device_id, device_name = row + user_devices.append(DeviceDto(device_name, device_id)) + + rows = connection.execute( + "select d.id, d.name, operation_type, operation_time from event_logs " + "join devices d on d.id = event_logs.device_id " + "WHERE event_logs.user_key=? " + "ORDER BY operation_time DESC", (user_key,) + ).fetchall() + + user_logs = [] + for row in rows: + device_id, device_name, operation_type, operation_time = row + device = DeviceDto(device_id, device_name) + + log = { + "device": device, + "operation": OperationDto(operation_time, operation_type), + } + + user_logs.append(log) + + return { + "user": { + "key": user_key, + "name": user_name, + "slack_id": slack_id + }, + "logs": user_logs, + "devices": user_devices + } + + +def delete_user(user_key): + connection = get_db_connection() + connection.execute("DELETE FROM users WHERE key=?", (user_key,)) + connection.commit() + logging.info('User with id %s was deleted', user_key) + + +def add_user(user_name, user_key): + connection = get_db_connection() + connection.execute("INSERT INTO users(name, key) VALUES(?,?)", (user_name, user_key)) + logging.info('User added: %s, %s', user_name, user_key) + connection.commit() + + +def get_all_users() -> list[UserDto]: + connection = get_db_connection() + cursor = connection.cursor() + cursor.execute("SELECT key, name FROM users") + rows = cursor.fetchall() + + users = [] + for row in rows: + key, name = row + user = UserDto(key, name) + users.append(user) + + return users diff --git a/app/features/users/user_routers.py b/app/features/users/user_routers.py new file mode 100644 index 0000000..57cb519 --- /dev/null +++ b/app/features/users/user_routers.py @@ -0,0 +1,52 @@ +import flask_login +from flask import ( + Blueprint, render_template +) +from flask import request + +from app.features.admin.init_app import get_db_connection +from app.features.slack_notifier import send_dm_message +from app.features.users.user_repository import delete_user, add_user, get_full_user + +user_blue_print = Blueprint('users', __name__, url_prefix='/user') + + +@user_blue_print.route('/', methods=['POST']) +@flask_login.login_required +def add_user_route(): + user_name = request.form['nick'] + user_key = request.form['key'] + add_user(user_name, user_key) + return 'OK' + + +@user_blue_print.route('/', methods=['DELETE']) +@flask_login.login_required +def delete_user_route(): + user_key = request.form['user_key'] + delete_user(user_key) + return 'OK' + + +@user_blue_print.route('/<user_key>', methods=['GET', 'POST']) +def user_page(user_key): + if request.method == 'POST': + slack_id = request.form['slack_id'] + + connection = get_db_connection() + connection.execute( + "UPDATE users SET slack_id=? WHERE key=?", + (slack_id, user_key) + ) + connection.commit() + + return render_template("user_page.html", full_user=get_full_user(user_key)) + + +@user_blue_print.route('/send-test-message-to-user', methods=['POST']) +@flask_login.login_required +def send_test_message_to_dm(): + json = request.json + user_key = json['user_key'] + send_dm_message(user_key, 'Test message') + return 'OK' diff --git a/app/routers/settings_routers.py b/app/routers/settings_routers.py new file mode 100644 index 0000000..84b4505 --- /dev/null +++ b/app/routers/settings_routers.py @@ -0,0 +1,41 @@ +import flask_login +from flask import Blueprint, request, render_template + +from app.config import set_setting, key_slack_token, key_slack_backup_channel, get_setting +from app.features.backup_database import backup_data_base + +settings_blue_print = Blueprint('settings', __name__) + + +@settings_blue_print.route('/settings', methods=['GET', 'POST']) +@flask_login.login_required +def index(): + if request.method == 'POST': + slack_token = request.form.get('slack_token') + channel_id = request.form.get('channel_id') + + if slack_token is not None: + set_setting(key_slack_token, slack_token) + + if channel_id is not None: + set_setting(key_slack_backup_channel, channel_id) + + settings = {} + saved_slack_token = get_setting(key_slack_token) + + if saved_slack_token is not None: + settings['slack_token'] = get_setting(key_slack_token) + + saved_channel_id = get_setting(key_slack_backup_channel) + if saved_channel_id is not None: + settings['channel_id'] = get_setting(key_slack_backup_channel) + + return render_template('settings.html', settings=settings) + + +@settings_blue_print.route('/send-backup-to-slack', methods=['POST']) +@flask_login.login_required +def send_backup_to_slack(): + print("Sending backup to slack") + backup_data_base() + return 'OK' diff --git a/app/utils/fimware_updater.py b/app/utils/fimware_updater.py new file mode 100644 index 0000000..af09618 --- /dev/null +++ b/app/utils/fimware_updater.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Disable pylint for the file +# pylint: disable=all + +"""Reader firmware update tool. It erases memory, flashes Micropython and +working scripts itself to connected device. + +@author: Artem Synytsyn <a.synytsyn@gmail.com> +""" +from flask import Flask, render_template +from flask_sock import Sock +import subprocess +import shlex +import pathlib + +# TODO: Read all these pathes from config file +ESPTOOL_PATH = "/home/artsin/Dev/esptool/esptool.py" +AMPY_PATH = "/home/artsin/.local/bin/ampy" +MICROPYTHON_DISTRO_PATH = "/home/artsin/Downloads/ESP32_GENERIC-20231005-v1.21.0.bin" + +READER_FW_PATH = "/home/artsin/Dev/prismo-reader/src/" + +command_erase = ["python3", ESPTOOL_PATH, "erase_flash"] +command_flash_micropython = [ + "python3", + ESPTOOL_PATH, + "--baud", + "460800", + "write_flash", + "-z", + "0x1000", + MICROPYTHON_DISTRO_PATH, +] +# TODO: Serial port autodetect +commands_upload_scripts = [AMPY_PATH, "-p", "/dev/ttyUSB0", "put"] + + +def erase_flash(socket): + process = subprocess.Popen(command_erase, stdout=subprocess.PIPE) + while True: + output = process.stdout.readline() + if output == "" and process.poll() is not None: + break + if output: + data = output.strip() + print(data) + socket.send(str(data)) + if b"Hard resetting via RTS pin" in data: + return True + return False + + +def flash_micropython_binary(socket): + process = subprocess.Popen(command_flash_micropython, stdout=subprocess.PIPE) + while True: + output = process.stdout.readline() + if output == "" and process.poll() is not None: + break + if output: + data = output.strip() + print(data) + socket.send(str(data)) + if b"Hard resetting via RTS pin" in data: + return True + return False + + +def upload_python_scripts(socket): + firmware_files = [f for f in pathlib.Path(READER_FW_PATH).iterdir() if f.is_file()] + for file in firmware_files: + socket.send("Download: " + str(file.name)) + # Make a copy, since we do not want to change original command template + command = commands_upload_scripts[:] + command.append(str(file)) + process = subprocess.Popen(command, stdout=subprocess.PIPE) + process.wait() + socket.send("OK") + return True + + +def update_firmware_full(socket): + socket.send("--------- FLASH ERASE ----------------") + result = erase_flash(socket) + print("Erase flash result: ", result) + socket.send("------- MICROPYTHON INSTALL ----------") + result = flash_micropython_binary(socket) + print("upload script") + socket.send("----------- UPLOAD FIRMWARE ----------") + upload_python_scripts(socket) + socket.send("----------- DONE! ----------") diff --git a/application.py b/application.py index 962cf57..cb2f87c 100755 --- a/application.py +++ b/application.py @@ -1,170 +1,153 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -""" -Core of Hacklab Admin Panel - -@author: Artem Synytsyn -""" - -from flask import Flask, render_template, request, abort -import time -import json -import yaml -import sys -import os import logging +import threading +import time from logging.handlers import RotatingFileHandler -from collections import namedtuple -from threading import Thread -from os.path import getmtime -import datetime -import psycopg2 as psycopg -import requests -import txt_log_reader -try: - from yaml import CLoader as Loader, CDumper -except ImportError: - from yaml import Loader - -# Configuration file -CONFIG_FILE = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'config.cfg') - -# Initial setup -try: - cfg = yaml.load(open(CONFIG_FILE, 'r'), Loader=Loader) -except IOError as e: - logger.error("Config file not found!") - logger.error("Exception: %s" % str(e)) - sys.exit(1) - -LATEST_KEY_FILE = cfg['data']['latest-key-file'] + +import flask +import flask_login +import schedule +from flask import Flask, render_template +from flask_login import LoginManager +from flask_sock import Sock +from flask import jsonify, request + +from app.config import cfg, UPLOAD_FOLDER, get_setting, key_secret_key, set_setting, \ + create_internal_config_file +from app.data.work_logs_repository import get_latest_key +from app.features.admin.admin_routrers import admin_blue_print +from app.features.admin.admins_repository import get_admin_user_by_flask_user, \ + get_flask_admin_user_by_id, \ + get_flask_admin_user_by_user_name +from app.features.admin.init_app import database_file +from app.features.backup_database import backup_data_base +from app.features.permissions.access_pannel import get_access_control_panel +from app.features.permissions.permission_routers import permissions_blue_print +from app.features.readers.manage_device import manage_device_blue_print +from app.features.readers.reader_routers import reader_blue_print +from app.routers.settings_routers import settings_blue_print +from app.features.users.user_routers import user_blue_print +from app.utils.fimware_updater import update_firmware_full + +from app.data.work_logs_repository import query_event_logs app = Flask(__name__) -app.config['SECRET_KEY'] = 'secret!' + +create_internal_config_file() + +secret_key = get_setting(key_secret_key) + +app.config['SECRET_KEY'] = secret_key +websocket = Sock(app) logger = logging.getLogger(__name__) -if cfg['logging']['debug'] is True: - app.config['DEBUG'] = True - logging.basicConfig(level=logging.DEBUG) - # Create logger to be able to use rolling logs - logger.setLevel(logging.DEBUG) - log_handler = RotatingFileHandler(cfg['logging']['logfile'],mode='a', - maxBytes=int(cfg['logging']['logsize_kb'])*1024, - backupCount=int(cfg['logging']['rolldepth']), - delay=0) - formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') - log_handler.setFormatter(formatter) - logger.addHandler(log_handler) - -def get_latest_key_info(): + +app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER + +login_manager = LoginManager() +login_manager.init_app(app) + +logging.basicConfig(level=logging.DEBUG) +# Create logger to be able to use rolling logs +logger.setLevel(logging.DEBUG) +log_handler = RotatingFileHandler(cfg['logging']['logfile'], mode='a', + maxBytes=int(cfg['logging']['logsize_kb']) * 1024, + backupCount=int(cfg['logging']['rolldepth']), + delay=0) +formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +log_handler.setFormatter(formatter) +logger.addHandler(log_handler) + +app.register_blueprint(reader_blue_print) +app.register_blueprint(permissions_blue_print) +app.register_blueprint(user_blue_print) +app.register_blueprint(admin_blue_print) +app.register_blueprint(settings_blue_print) +app.register_blueprint(manage_device_blue_print) + + +def scheduler_thread(): + while True: + schedule.run_pending() + time.sleep(1) + + +schedule.every().day.at("22:17").do(backup_data_base) + +scheduler = threading.Thread(target=scheduler_thread) +scheduler.daemon = True +scheduler.start() + + +# noinspection PyBroadException +@login_manager.user_loader +def loader_user(user_id): + # pylint: disable=broad-exception-caught try: - with open(LATEST_KEY_FILE, 'r') as f: - key_value = f.read() - except FileNotFoundError: - key_value = '<absent>' - # Getting modification datetime + return get_flask_admin_user_by_id(user_id) + except Exception: + return None + + +# noinspection PyBroadException +@login_manager.request_loader +def request_loader(request): + # pylint: disable=broad-exception-caught + username = request.form.get('username') try: - mod_time = getmtime(LATEST_KEY_FILE) - mod_time_converted = datetime.datetime.fromtimestamp( - mod_time).strftime('%Y-%m-%d %H:%M:%S') - except OSError: - mod_time_converted = '<unknown>' - return ("%s updated at: %s" % (key_value, mod_time_converted)) + print(f"user name: {username}") + return get_flask_admin_user_by_user_name(username) + except Exception: + print(f"none: {username}") + return None -@app.route('/', methods=['GET', 'POST']) +@app.route('/', methods=['GET']) def index(): - try: - conn = psycopg.connect(user = cfg['data']['user'], - password = cfg['data']['password'], - host = cfg['data']['host'], - port = cfg['data']['port'], - database = cfg['data']['name']) - except (Exception, psycopg.DatabaseError) as error : - logger.error("Error while connecting to PostgreSQL: %s" % error) - abort(500) - cursor = conn.cursor() - cursor.execute('SELECT * FROM users') - all_column_names = list(description[0] for description in - cursor.description) - - # Get column names for devices, needed access control. Exclude the others - access_info_columns = list(filter(lambda value: not value in ['id', 'name', 'key', 'last_enter'], all_column_names)) - ordered_column_names = ['id', 'name', 'key', 'last_enter'] - ordered_column_names.extend(access_info_columns) - - cursor.execute('SELECT id, name, key, last_enter FROM users') - user_info = cursor.fetchall() - cursor.execute('SELECT %s FROM users' - % ','.join(access_info_columns)) - user_access_info = cursor.fetchall() - template_data = zip(user_info, user_access_info) - - # Updating latest key information - latest_key_info = get_latest_key_info() - # Parsing data from frontend: editing access, user additioin and deletion - if request.method == 'POST': - operation = request.form['operation'] - if operation == 'edit': - user_id = request.form['id'] - user_device = request.form['device'] - user_state = request.form['state'] - if user_state == 'true': - user_state = '1' - elif user_state == 'false': - user_state = '0' - - logger.info('Updated user info: %s, %s, %s' % (user_id, - access_info_columns[int(user_device)], - user_state)) - command = "UPDATE users SET %s = '%s' WHERE id = %s" \ - % (access_info_columns[int(user_device)], user_state, - user_id) - cursor.execute(command) - conn.commit() - elif operation == 'delete': - user_id = request.form['id'] - user_device = request.form['device'] - user_state = request.form['state'] - cursor.execute('DELETE FROM users WHERE id=%s', (user_id, )) - conn.commit() - logger.info('User deleted, id: %s' % user_id) - elif operation == 'add': - user_name = request.form['nick'] - user_key = request.form['key'] - cursor.execute('INSERT INTO users(name, key) VALUES(%s, %s)',(user_name, user_key)) - conn.commit() - logger.info('User added: %s, %s' % (user_name, user_key)) - cursor.close() - conn.close() - return render_template('index.html', data=template_data, - column_names=ordered_column_names, - latest_key_info=latest_key_info) - -@app.route("/log_viewer") -def log_reader_wrapper(): - return txt_log_reader.render_logs_to_html() - - -@app.route('/log_view_2') -def log_view_2(): - try: - conn = psycopg.connect(user=cfg['data']['user'], - password=cfg['data']['password'], - host=cfg['data']['host'], - port=cfg['data']['port'], - database=cfg['data']['name']) - except (Exception, psycopg.DatabaseError) as error: - logger.error("Error while connecting to PostgreSQL: %s" % error) - abort(500) - cursor = conn.cursor() + if not database_file.is_file(): + return flask.redirect(flask.url_for('admin.init_app_route')) + if flask_login.current_user.is_authenticated: + return flask.redirect(flask.url_for('access_panel')) + return flask.redirect(flask.url_for('admin.login')) + + +@app.route('/access_panel', methods=['GET']) +def access_panel(): + access_control_panel = get_access_control_panel() + latest_key = get_latest_key() + + user = get_admin_user_by_flask_user(flask_login.current_user) + if user is None: + current_username = "Anonymous" + else: + current_username = user.username + + logger.info('Access control panel data: %s', access_control_panel) + logger.info('Latest key: %s', latest_key) + + return render_template("access_panel.html", + latest_key=latest_key, + access_control_panel=access_control_panel, + current_user=current_username) + + +@app.route('/full_log_view') +def full_log_view(): + return render_template('full_log_view.html') + - cursor.execute('select device_name, name, to_timestamp(time) from logs join users u on logs.key = u.key;') +# TODO all API calls, including API for readers move to separate module. +@app.route('/api/logs', methods=['GET']) +def api_get_logs(): + # TODO: error handling, input data validation etc. + # Retrieve parameters from the query string + start_time = request.args.get('start_time', default=None) + end_time = request.args.get('end_time', default=None) + limit = request.args.get('limit', default=100, type=int) + offset = request.args.get('offset', default=0, type=int) - logs = cursor.fetchall() + return jsonify(query_event_logs(start_time, end_time, limit, offset)) - print(logs) - cursor.close() - conn.close() - return render_template('log_view_2.html', logs=logs) +@websocket.route('/updater_socket') +def updater(websocket): + # pylint: disable=redefined-outer-name + update_firmware_full(websocket) diff --git a/config.cfg b/config.cfg new file mode 100644 index 0000000..e944619 --- /dev/null +++ b/config.cfg @@ -0,0 +1,4 @@ +logging: + logfile: log.txt + logsize_kb: 1000 + rolldepth: 3 diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..b6b49d8 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,82 @@ +## Api for readers + +#### Get all keys with access to device + +Method: GET +Path: `/reader/<device_id>/accesses/` +Response: + +```json +{ + "keys": [ + "value_1", + "value_2" + ] +} +``` + +#### Log an operation + +Method: POST +Path: `/reader/<device_id>/log_operation` +List of operations: `lock`, `unlock`, `deny_access` +Body for lock: + +```json +{ + "operation": "lock" +} +``` + +Body for unlock: + +```json +{ + "operation": "unlock", + "data": { + "key": "<user key>" + } +} +``` + + +Body for deny: + +```json +{ + "operation": "deny_access", + "data": { + "key": "<user key>" + } +} +``` + +#### Get event log data + +Method: GET +Path: `/api/logs` +Query Parameters: + +1. start_time (optional): The start time of the time range (format: 'YYYY-MM-DD HH:MM:SS'). +2. end_time (optional): The end time of the time range (format: 'YYYY-MM-DD HH:MM:SS'). +3. limit (optional): The maximum number of log entries to retrieve (default: 100). + +Response: + +```json +[ + { + "id": "d2db5ec4-6e7a-11ee-b962-0242ac120002", + "key": "2fc49ee397fc41a2c8721f86d7f87bb2c560c01d7b19bc1654fb5db9beaa19ad", + "name": "MyTestCard", + "operation_time": "2023-11-07 10:17:12", + "operation_type": "lock" + }, + // More log entries +} +``` +Example usage + +``` +GET /api/log?start_time=2023-01-01%2012:00:00&end_time=2023-01-02%2012:00:00&limit=50 +``` diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..d4ef39e --- /dev/null +++ b/docs/database.md @@ -0,0 +1,56 @@ +## The database structure + +The application creates the database by itself. The code for creating database is in `app/data/database.py` file. + +### Project data structure + +All data stored in postgres database. Currently, we have three tables: + +- users - the table with users data +- permissions - the table with permissions. It contains two columns: device_id and user_key. Device_id is unique id of + device, user_key is key of user, who has access to this device. +- event_logs - the jornal of all events. It contains four columns: user_key, device_id, operation and time. Operation + can be `lock`, `unlock`, ... + +#### Database + +We use the sqlite database, the file for database is stored in `database.db` file. The file is not created by default. +You need to create it manually. See section "Prepare database" + +Schema of database: + +```mermaid +classDiagram + direction BT + class devices { + text id + text name + } + class permissions { + text device_id + text user_key + } + + class users { + text name + text key + } + + class event_long { + text user_key + text device_id + text operation + text time + } + + class admins { + integer id + text username + text password + } + + permissions --> devices + work_logs --> devices + work_logs --> users + permissions --> users +``` diff --git a/readres.http b/readres.http new file mode 100644 index 0000000..ea97fd7 --- /dev/null +++ b/readres.http @@ -0,0 +1,24 @@ +GET http://localhost:5000/reader/name1/accesses + + +### +POST http://127.0.0.1:5000/reader/test_slack_device/log_operation +Content-Type: application/json + +{ + "operation": "lock", + "data": { + "key": "my_slack_key" + } +} + +### +POST http://127.0.0.1:5000/reader/test_slack_device/log_operation +Content-Type: application/json + +{ + "operation": "unlock", + "data": { + "key": "my_slack_key" + } +} diff --git a/requirements.txt b/requirements.txt index 88cac4d..850521c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,11 @@ -Flask -pyyaml -psycopg2-binary +Flask~=2.3.2 +pyyaml~=6.0 requests +slack_sdk +Werkzeug==2.3.7 +flask-sock +flask-login +schedule +pylint +pycodestyle +gunicorn==20.0.4 \ No newline at end of file diff --git a/router/mac b/router/mac deleted file mode 100644 index bf2fcb9..0000000 --- a/router/mac +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env lua - -print("Content-type: application/json") -print("") - -local ip = os.getenv("REMOTE_HOST") -local first = true - -io.write('{"ip": "', ip, '", "macs": [') -for line in io.lines('/proc/net/arp') do - fields = {} - line:gsub("([^ ]*)".." ", function(c) - if c ~= "" then - table.insert(fields, c) - end - end) - if fields[1] == ip then - if first then - first = false - else - io.write(", ") - end - io.write('"', fields[4], '"') - end -end -io.write("]}") diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2776ed3 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[pycodestyle] +count = False +max-line-length = 100 +statistics = True \ No newline at end of file diff --git a/static/css/auth.css b/static/css/auth.css new file mode 100644 index 0000000..86c53ef --- /dev/null +++ b/static/css/auth.css @@ -0,0 +1,88 @@ +body { + font-family: Arial, sans-serif; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + margin: 0; + background-color: #f0f0f0; +} + +.login-container { + background: #fff; + padding: 20px; + border-radius: 5px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); + width: 300px; + text-align: center; +} + +.login-container h2 { + margin-top: 0; +} + +.login-form { + text-align: left; +} + +.login-form label, +.login-form input { + display: block; + margin-bottom: 10px; +} + +.login-form input { + width: 100%; + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; +} + +.login-form button { + background-color: #007bff; + color: #fff; + padding: 10px 20px; + border: none; + border-radius: 5px; + cursor: pointer; +} + +/* Style the checkbox and its label */ +#toggleCheckbox { + display: none; /* Hide the default checkbox */ +} + +#toggleCheckbox + label { + display: flex; + align-items: center; + margin-right: 10px; +} + +#toggleCheckbox + label::before { + content: ""; + display: inline-block; + width: 20px; + height: 20px; + border: 1px solid #ccc; + border-radius: 3px; + background-color: #fff; + margin-right: 10px; /* Adjust the spacing between checkbox and label text */ +} + +#toggleCheckbox:checked + label::before { + background-color: #007bff; /* Change background color when checked */ + border: 1px solid #007bff; +} + +/* Style the content to be hidden or shown */ +#contentToToggle { + display: none; + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; + background-color: #fff; +} + +#toggleCheckbox:checked + label + #contentToToggle { + display: block; +} \ No newline at end of file diff --git a/static/js/application.js b/static/js/application.js index c0dada7..7b053ee 100644 --- a/static/js/application.js +++ b/static/js/application.js @@ -1,170 +1,11 @@ -/** - * Javascript part of Hacklab Admin Panel. - * includes SocketIO communication with backend and - * implementation of general visual logic - * - * TODO: - * * Implement save, discard, delete logic for user data table - * * Add comments for functions - * * Code review - */ -var DELETE_EDIT_BUTTON_GROUP = '<td class="buttonToolbox">' + - '<div class="btn-group"><a class="btn btn-danger btn-sm" onclick="deleteRow(this)">' + - '<i class="fa fa-trash-o"></i></a>' + - '<a class="btn btn-info btn-sm" onclick="editRow(this)">' + - '<i class="fa fa-pencil"></i></a></div></td>'; - -var DISCARD_APPLY_BUTTON_GROUP = '<div class="btn-group">' + - '<a class="btn btn-danger btn-sm" onclick="discardChanges(this)">' + - '<i class="fa fa-undo" aria-hidden="true"></i></a>' + - '<a class="btn btn-primary btn-sm" onclick="applyChanges(this)">' + - '<i class="fa fa-floppy-o" aria-hidden="true"></i></a></div>'; - -var MAC_CONVERSION_SERVICE = "http://192.168.1.1/cgi-bin/mac"; - -var macs = []; - -$(function() { - $.getJSON( MAC_CONVERSION_SERVICE, function( data ) { - macs = data.macs; - }); -}); - -$(function() { - //connect to the socket server. - var socket = io.connect('http://' + document.domain + ':' + location.port + '/log'); - var msgReceived = []; - - //receive details from server - socket.on('newmessage', function(msg) { - msgReceived.push(msg); - numbers_string = ''; - // TODO: Maybe here we could use append function? - $('#log').html(numbers_string); - for (var i = 0; i < msgReceived.length; i++) { - numbers_string = numbers_string + msgReceived[i] + '<br>'; - console.log(i); - } - $('#log').html(numbers_string); - }); - -}); - -$(function() { - var socket = io.connect('http://' + document.domain + ':' + location.port + '/tablemgr'); - socket.on('tabledata', function(msg) { - var table_data = JSON.parse(msg); - var index; - $("#table_body").empty(); - $("#table_full_status_body").empty(); - for (index = 0; index < table_data.length; ++index) { - console.log(table_data[index]); - addTableRow(table_data[index]); - } - }); -}); - -function addTableRow(data) { - if (data.hasOwnProperty('id')) { - var decorated = (data.status == 'online' ? '<strong>online</strong>' : 'offline'); - $("#tblData tbody").append( - "<tr>" + - '<td class="id_entry">' + data.id + "</td>" + - '<td class="mac_entry" data-mac="' + data.mac + '">' + - data.mac.replace(/;/g, '<br>') + "</td>" + - '<td class="status_entry">' + decorated + "</td>" + - '<td class="nick_entry">' + data.nick + "</td>" + - DELETE_EDIT_BUTTON_GROUP + - "</tr>"); - } - if (data.status === 'online') { - var decorated = ($.inArray(data.mac, macs) == -1 ? '' : ' (<strong>you!</strong>)'); - $("#tblDataFullStatus tbody").append( - '<tr>' + - '<td class="mac_entry">' + data.mac + decorated + "</td>" + - '<td class="nick_entry">' + data.nick + "</td>" + - '</tr>'); - } -}; - -function discardChanges(context) { - var socket = io.connect('http://' + document.domain + ':' + location.port + '/tablemgr'); - // Just ask server resend the table - socket.emit('uploadTable', {}); -}; - -function editRow(context) { - var $row = $(context).closest("tr"); - - var id = $row.find(".id_entry"); - var mac = $row.find(".mac_entry"); - var nick = $row.find(".nick_entry"); - var buttonToolbox = $row.find(".buttonToolbox"); - // Store previous values for undo - var prevNick = nick.text(); - var prevMac = mac.attr("data-mac"); - - console.log(prevNick); - mac.html("<input type='text' id='newmac' data-toggle='tooltip'" + - "data-placement='top' title='Use " ; " separator to add multiple MACs' value='" + - mac.attr("data-mac") + "'/>"); - nick.html("<input type='text' id='newnick' value='" + nick.text() + "'/>"); - - // Change buttons to "Save" and "Apply" - buttonToolbox.html(DISCARD_APPLY_BUTTON_GROUP); -}; - -function applyChanges(context) { - var socket = io.connect('http://' + document.domain + ':' + location.port + '/tablemgr'); - var $row = $(context).closest("tr"); - var id = $row.find(".id_entry").text(); - var mac = $row.find(".mac_entry"); - var nick = $row.find(".nick_entry"); - var buttonToolbox = $row.find(".buttonToolbox"); - - var newMacValue = document.getElementById("newmac").value; - var newNickValue = document.getElementById("newnick").value; - - // Convert all symbols to upper, just to convenience - newMacValue = newMacValue.toUpperCase(); - - mac.html(newMacValue); - nick.html(newNickValue); - - buttonToolbox.html(DELETE_EDIT_BUTTON_GROUP); - - socket.emit('edituser', { - id: id, - mac: newMacValue, - nick: newNickValue, - }); -}; - -function deleteRow(context) { - var socket = io.connect('http://' + document.domain + ':' + location.port + '/tablemgr'); - var $row = $(context).closest("tr"); - var id = $row.find(".id_entry").text(); - - socket.emit('deleteuser', { - id: id, - }); -}; - -function addUser() { - var socket = io.connect('http://' + document.domain + ':' + location.port + '/tablemgr'); - var nickname = $("#nickname").val(); - var macaddress = $("#macaddress").val(); - - if (nickname == "") { - alert("Please specify nickname!"); - return 0; - } - if (macaddress == "") { - alert("Please specify MAC address!"); - return 0; - } - socket.emit('adduser', { - macaddress: macaddress, - nickname: nickname +function flashFirmware() { + console.log("Test!"); + const socket = new WebSocket('ws://' + location.host + '/updater_socket'); + const log = (text, color) => { + document.getElementById('flashLog').innerHTML += `<span style="color: ${color}">${text}</span><br>`; + }; + + socket.addEventListener('message', ev => { + log('<<< ' + ev.data, 'blue'); }); -}; +} diff --git a/static/js/macs.js b/static/js/macs.js deleted file mode 100644 index b6557f6..0000000 --- a/static/js/macs.js +++ /dev/null @@ -1,53 +0,0 @@ -$(document).ready(function() { - $('.select2').select2(); -}); - -function addMac() { - var user_id = $("#user_id").val(); - var mac = $("#mac").val(); - var data = new FormData(); - - if (user_id == "") { - alert("Please select a person!"); - return 0; - } - if (mac == "") { - alert("Please specify MAC!"); - return 0; - } - - data.append('operation', 'add'); - data.append('user_id', user_id); - data.append('mac', mac); - - var xhr = new XMLHttpRequest(); - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status != 200) { - alert("Cannot get updated table!"); - } else { - location.reload(true); - } - } - } - xhr.open('POST', '/macs', true); - xhr.send(data); -} - -function deleteMac(mac){ - var data = new FormData(); - data.append('operation', 'delete'); - data.append('mac', mac); - var xhr = new XMLHttpRequest(); - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status != 200) { - alert("Cannot get updated table!"); - } else { - location.reload(true); - } - } - } - xhr.open('POST', '/macs', true); - xhr.send(data); -} diff --git a/static/js/rfid.js b/static/js/rfid.js index 176c9b4..d29cbaf 100644 --- a/static/js/rfid.js +++ b/static/js/rfid.js @@ -1,73 +1,116 @@ -function onCheckboxChange(context) { - var data = new FormData(); - data.append('operation', 'edit'); - data.append('id', context.value.split(",")[1]); - data.append('device', context.value.split(",")[0]); - data.append('state', context.checked); - - var xhr = new XMLHttpRequest(); - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status != 200) { - alert("Cannot get updated table!"); - } - } - } - xhr.open('POST', '/', true); - xhr.send(data); +function onUserPermissionChange(user_key, device_id, context) { + console.log('change permission for user ' + user_key + ' on device ' + device_id); + + var data = new FormData(); + data.append('user_key', user_key); + data.append('device_id', device_id); + + if (context.checked) { + method = 'POST'; + } else { + method = 'DELETE'; + } + + console.log('method: ' + method); + + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function () { + if (xhr.readyState == 4) { + if (xhr.status != 200) { + alert("Cannot get updated table!"); + } else { + location.reload(); + } + } + } + xhr.open(method, '/permission', true); + xhr.send(data); } -function onDeleteClick(id_value, a) { - var data = new FormData(); - data.append('operation', 'delete'); - data.append('id', id_value); - data.append('device', ''); - data.append('state', ''); - - var xhr = new XMLHttpRequest(); - var table = document.getElementById('tblData'); - var rowIndex = a.parentNode.parentNode.rowIndex; - table.deleteRow(rowIndex); - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status != 200) { - alert("Cannot get updated table!"); - } - } - } - xhr.open('POST', '/', true); - xhr.send(data); +function onUserDeleteClick(user_key, a) { + console.log('onUserDeleteClick ' + user_key); + + var data = new FormData(); + data.append('user_key', user_key); + + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function () { + if (xhr.readyState == 4) { + if (xhr.status != 200) { + alert("Cannot get updated table!"); + } else { + // Reload page when we received response + location.reload(); + } + } + } + xhr.open('DELETE', '/user', true); + xhr.send(data); } function addUser() { - var name = $("#user_name").val(); - var key = $("#user_key").val(); - var data = new FormData(); - - if (name == "") { - alert("Please specify nickname!"); - return 0; - } - if (key == "") { - alert("Please specify access key!"); - return 0; - } - - data.append('operation', 'add'); - data.append('nick', name); - data.append('key', key); - - var xhr = new XMLHttpRequest(); - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status != 200) { - alert("Cannot get updated table!"); - } else { - // Reload page when we received response - location.reload(true); - } - } - } - xhr.open('POST', '/', true); - xhr.send(data); + var name = $("#user_name").val(); + var key = $("#user_key").val(); + var data = new FormData(); + + if (name == "") { + alert("Please specify nickname!"); + return 0; + } + if (key == "") { + alert("Please specify access key!"); + return 0; + } + + data.append('nick', name); + data.append('key', key); + + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function () { + if (xhr.readyState == 4) { + if (xhr.status != 200) { + alert("Cannot get updated table!"); + } else { + // Reload page when we received response + location.reload(true); + } + } + } + xhr.open('POST', '/user', true); + xhr.send(data); } + +function addDevice() { + console.log("Add device called"); + var device_name = $("#device_name").val(); + var device_id = $("#device_id").val(); + var data = new FormData(); + + if (device_name == "") { + alert("Please specify device_name!"); + return 0; + } + if (device_id == "") { + alert("Please specify device_id!"); + return 0; + } + + data.append('device_name', device_name); + data.append('device_id', device_id); + + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function () { + if (xhr.readyState == 4) { + if (xhr.status != 200) { + alert("Cannot get updated table!"); + } else { + // Reload page when we received response + location.reload(true); + } + } + } + xhr.open('POST', '/device', true); + xhr.send(data); + + console.log(data); +} \ No newline at end of file diff --git a/templates/access_panel.html b/templates/access_panel.html new file mode 100644 index 0000000..64372c8 --- /dev/null +++ b/templates/access_panel.html @@ -0,0 +1,67 @@ +{% extends "layout.html" %} +{% block content %} +<div class="container-fluid px-md-5"> + <div class="row"> + <div class="form-group col-md-5 col-sm-12" data-step="2"> + <h1 class="my-4">Access control</h1> + </div> + <div class="form-group col-md-5 col-sm-12" data-step="3"> + <h1 class="my-4">{{ current_user }}</h1> + </div> + </div> + + <div class="well well-sm pb-4" data-step="1" + data-intro="Latest used key on DOOR is shown here. Put new key to door reader and check modify datetime">Latest + triggered door key: {{ latest_key }} + </div> + <div class="row"> + <div class="form-group col-md-5 col-sm-12" data-step="2" data-intro="Add this new key here."> + <label for="email">KEY Value: </label> + <input type="text" class="form-control" id="user_key" value={{ latest_key }}> + </div> + <div class="form-group col-md-5 col-sm-12" data-step="3" data-intro="Fill the nickname"> + <label for="pwd">Nickname: </label> + <input type="text" class="form-control" id="user_name" placeholder="Nickname"> + </div> + <div class="col-md-2 col-sm-10 d-flex align-items-end"> + <button type="submit" class="btn btn-primary" onclick="addUser()" data-step="4" + data-intro="Finish addition">Add user + </button> + </div> + </div> + <div class="py-3"> + <div class="py-3 table-responsive"> + <table id="tblData" class="table stripe hover align-middle dataTable no-footer compact pt-3"> + <thead> + <tr> + {% for column_name in access_control_panel.header %} + <th>{{ column_name }}</th> + {% endfor %} + <th></th> + </tr> + </thead> + <tbody id="table_body"> + {% for row in access_control_panel.rows %} + <tr> + <td><a href="/user/{{row.user_key}}">{{row.user_name}}</a></td> + <td>{{row.user_key}}</td> + {% for permission in row.permissions %} + <td> + <input type="checkbox" name="box1" + onChange="onUserPermissionChange('{{row.user_key}}', '{{permission.device_key}}', this)" + value="box1" {{ 'checked="checked"' if permission.is_granted else ""}}> + </td> + {% endfor %} + <td> + <a class="btn btn-danger btn-sm" onclick="onUserDeleteClick('{{row.user_key}}', this)"> + <i style="color: white" class="fa fa-trash-o"></i> + </a> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> +</div> +{% endblock %} diff --git a/templates/device_page.html b/templates/device_page.html new file mode 100644 index 0000000..2f7af25 --- /dev/null +++ b/templates/device_page.html @@ -0,0 +1,66 @@ +{% extends "layout.html" %} + +{% block content %} + +<div class="container-fluid px-md-5"> + <h1 class="my-4">Profile for device : {{ full_device.device.name }}</h1> + <div> + <form method="post" action="#"> + <label for="slack_channel_id">Slack ID:</label> + <input type="text" id="slack_channel_id" name="slack_channel_id" required value={{ + full_device.device.slack_channel_id }}> + + <input type="submit" value="Set device slack channel id for notification"> + </form> + + <button id="execute-button">Send sent notification</button> + </div> + <div class="py-3"> + <div style="width: 100%;"> + <div style="float:left; width: 30%"> + <h4>User with access to {{ full_device.device.name }}</h4> + <ul> + {% for user in full_device.user_with_access %} + <li><a href="/user/{{user.key}}">{{ user.name }}</a></li> + {% endfor %} + </ul> + </div> + <div style="float:right; width: 70%"> + <table class="table stripe hover align-middle dataTable no-footer compact pt-3"> + <tr> + <th>User name</th> + <th>Time</th> + <th>Operation</th> + </tr> + {% for log in full_device.logs %} + <tr> + <td><a href="/user/{{ log.user.key }}">{{ log.user.name }}</a></td> + <td>{{ log.operation.time }}</td> + <td>{{ log.operation.type }}</td> + </tr> + {% endfor %} + </table> + </div> + </div> + <div style="clear:both"></div> + </div> +</div> + +<script> + $(document).ready(function () { + let jsonData = { + device_id: "{{ full_device.device.id }}", + }; + $('#execute-button').click(function () { + $.ajax({ + type: 'POST', + url: '/send-test-message-to-channel', + data: JSON.stringify(jsonData), + contentType: 'application/json', + }); + }); + }); +</script> + + +{% endblock %} diff --git a/templates/devices.html b/templates/devices.html new file mode 100644 index 0000000..c2edbcc --- /dev/null +++ b/templates/devices.html @@ -0,0 +1,63 @@ +{% extends "layout.html" %} + +{% block content %} + +<div class="container-fluid px-md-5"> + <div class="row"> + <div class="form-group col-md-5 col-sm-12" data-step="2" data-intro="Add this new key here."> + <label for="email">Device name: </label> + <input type="text" class="form-control" id="device_id" placeholder="c32d8b45-92fe-44f6-8b61-42c2107dfe87"> + </div> + <div class="form-group col-md-5 col-sm-12" data-step="3" data-intro="Fill the nickname"> + <label for="pwd">Reader Name: </label> + <input type="text" class="form-control" id="device_name" placeholder="Device name, eg Lathe or Door"> + </div> + <div class="col-md-2 col-sm-10 d-flex align-items-end"> + <button type="submit" class="btn btn-primary" onclick="addDevice()" data-step="4" + data-intro="Finish addition">Add device + </button> + </div> + </div> + <h1 class="my-4">All devices</h1> + <div class="py-3"> + <div style="float:left; width: 30%"> + <ul> + {% for device in devices %} + <li><a href="/device/{{device.id}}">{{ device.name }}:{{device.id}}</a></li> + {% endfor %} + </ul> + </div> + <div style="clear:both"></div> + </div> + <div class="py-3"> + <div style="float:left; width: 30%"> + <!-- Button trigger modal --> + <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#flashDeviceModal"> + Flash Connected Device + </button> + <!-- Modal --> + <div class="modal fade" id="flashDeviceModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true"> + <div class="modal-dialog modal-dialog-scrollable" role="document"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title" id="exampleModalLabel">Flash Device</h5> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> + </div> + <div class="modal-body" id="flashLog"> + ... + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button> + <button type="button" class="btn btn-primary" onclick="flashFirmware()">Flash Firmware</button> + </div> + </div> + </div> + </div> + </div> + <div style="clear:both"></div> + </div> + +</div> +{% endblock %} diff --git a/templates/full_log_view.html b/templates/full_log_view.html new file mode 100644 index 0000000..f48d669 --- /dev/null +++ b/templates/full_log_view.html @@ -0,0 +1,69 @@ +{% extends "layout.html" %} + +{% block content %} + +<div class="container-fluid px-md-5"> + <h1 class="my-4">Logs</h1> + <div class="row"> + <div class="form-group col-md-3 col-sm-12" data-step="1"> + <label>Start time:</label> + <input type="text" class="form-control" id="start_time" type="datetime"> + </div> + <div class="form-group col-md-3 col-sm-12" data-step="2"> + <label>End time:</label> + <input type="text" class="form-control" id="end_time" type="datetime"> + </div> + <div class="col-md-2 col-sm-10 d-flex align-items-end" data-step="3"> + <button type="submit" class="btn btn-primary" onclick="addTimeRange()"> + Search + </button> + </div> + </div> + <div class="py-3"> + <table id="logsTable" class="table stripe hover align-middle dataTable no-footer compact pt-3"> + </table> + </div> + +</div> + +<script> + function addTimeRange() { + let startTime = $('#start_time').val(); + let endTime = $('#end_time').val(); + let url = '/api/logs?limit=5000'; + if (startTime) { + url += '&start_time=' + startTime; + } + if (endTime) { + url += '&end_time=' + endTime; + } + $('#logsTable').DataTable().ajax.url(url).load(); + } + $('#logsTable').DataTable({ + ajax: { + url: '/api/logs?limit=5000', + dataSrc: '' + }, + columns: [ + { + data: 'name', + title: 'Name', + render: (data, type, row) => { + if (data === null) return '–' + return '<a href="/user/' + row.key + '">' + data + '</a>' + } + }, + { + data: 'id', + title: 'Device', + render: (data, type, row) => '<a href="/device/' + data + '">' + data + '</a>' + }, + { data: 'operation_type', title: 'Operation Type' }, + { data: 'operation_time', title: 'Time' }, + ], + order: [[3, 'desc']], + pageLength: 100 + }); +</script> + +{% endblock %} diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index ee0f2fb..0000000 --- a/templates/index.html +++ /dev/null @@ -1,69 +0,0 @@ -{% extends "layout.html" %} -{% block content %} -<div class="container-fluid px-md-5"> - <h1 class="my-4">Access control</h1> - <div class="well well-sm pb-4" data-step="1" - data-intro="Latest used key on DOOR is shown here. Put new key to door reader and check modify datetime" - >Latest triggered door key: {{ latest_key_info }} - </div> - <div class="row"> - <div class="form-group col-md-5 col-sm-12" data-step="2" data-intro="Add this new key here."> - <label for="email">KEY Value: </label> - <input type="text" class="form-control" id="user_key" placeholder="123ABC" > - </div> - <div class="form-group col-md-5 col-sm-12" data-step="3" data-intro="Fill the nickname"> - <label for="pwd">Nickname: </label> - <input type="text" class="form-control" id="user_name" placeholder="Nickname"> - </div> - <div class="col-md-2 col-sm-10 d-flex align-items-end"> - <button type="submit" class="btn btn-primary" onclick="addUser()" data-step="4" data-intro="Finish addition">Add user</button> - </div> - </div> - <div class="py-3"> - <!-- Table with database --> - <div class="py-3 table-responsive"> - <table id="tblData" class="table stripe hover align-middle dataTable no-footer compact pt-3"> - <thead> - <tr> - {% for column_name in column_names %} - <th>{{ column_name }}</th> - {% endfor %} - <th></th> - </tr> - </thead> - <tbody id="table_body"> - {% for item in data %} - <!-- User information part --> - <tr> - {% for subitem in item[0][:-1] %} - <td>{{ subitem }}</td> - {% endfor %} - <!-- Last enter part --> - <td> - {% if item[0][3] %} - {{ item[0][3].strftime('%Y-%m-%d %H:%M') }} - {% else %} - – - {% endif %} - </td> - <!-- Access information part --> - {% for subitem in item[1] %} - <td> - <!-- Value is encoded like: (x,y), where x - is index of access - devices(door, cnc, etc), y - user ID value --> - <input type="checkbox" onChange='onCheckboxChange(this)' - name="accessBox" value={{loop.index - 1}},{{item[0][0]}} - {% if subitem==1 %}checked{% endif %}> - </td> - {% endfor %} - <td> - <a class="btn btn-danger btn-sm" onclick="onDeleteClick({{item[0][0]}}, this)"><i style="color: white" class="fa fa-trash-o"></i></a> - </td> - </tr> - {% endfor %} - </tbody> - </table> - </div> - </div> -</div> -{% endblock %} diff --git a/templates/init_app.html b/templates/init_app.html new file mode 100644 index 0000000..f3912d2 --- /dev/null +++ b/templates/init_app.html @@ -0,0 +1,50 @@ +{% block content %} + +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Login Form</title> + <link rel="stylesheet" href="/static/css/auth.css"> +</head> +<body> +<div class="login-container"> + <h2>Create your admin user</h2> + <p>We suggest and highly recommend to create a strong password for your admin user</p> + <form class="login-form" action="#" method="post" enctype="multipart/form-data"> + <label for="username">Admin username:</label> + <input type="text" id="username" name="username" required> + + <label for="password">Admin Password:</label> + <input type="password" id="password" name="password" required> + + <label for="slat">Slat, the secure string using for hash the admin password</label> + <label for="slat">We recommend to generate random string for the field, and keep it save</label> + <input type="text" id="slat" name="slat" required> + + <input type="checkbox" id="toggleCheckbox"> + <label for="toggleCheckbox">Restore from database backup</label> + + <div id="contentToToggle"> + <input type=file name=file> + </div> + + <div> + <button type="submit">Init Prismo</button> + </div> + </form> +</div> +<script> + const toggleCheckbox = document.getElementById("toggleCheckbox"); + const contentToToggle = document.getElementById("contentToToggle"); + + toggleCheckbox.addEventListener("change", function () { + console.log("test") + contentToToggle.style.display = this.checked ? "block" : "none"; + }); + contentToToggle.style.display = "none"; +</script> +</body> +</html> + +{% endblock %} diff --git a/templates/layout.html b/templates/layout.html index 25023ff..253b3f7 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -1,52 +1,51 @@ <!DOCTYPE html> -<html> +<html lang="en"> <head> <title>Prismo</title> <script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script> <script src="//cdnjs.cloudflare.com/ajax/libs/intro.js/2.7.0/intro.js"></script> <script src="static/js/rfid.js"></script> - <script src="static/js/macs.js"></script> - <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css"> + <script src="static/js/application.js"></script> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/intro.js/2.7.0/introjs.css"> - <link href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.8/css/select2.min.css" rel="stylesheet" /> + <link href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.8/css/select2.min.css" rel="stylesheet"/> <script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.8/js/select2.min.js"></script> <link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/v/dt/dt-1.13.1/datatables.min.css"/> <script type="text/javascript" src="https://cdn.datatables.net/v/dt/dt-1.13.1/datatables.min.js"></script> + <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <meta name="viewport" content="width=device-width, initial-scale=1.0"> </head> <body> - <nav class="navbar navbar-expand-lg navbar-light bg-dark px-5" role="navigation"> - <a class="navbar-brand text-light" href="/">Hacklab Admin Panel</a> - <div class="collapse navbar-collapse" id="navbarSupportedContent"> - <ul class="navbar-nav mr-auto"> - <li class="nav-item active"><a class="nav-link text-light" href="/">Access control</a></li> - <li class="nav-item"><a class="nav-link text-light" href="macs">MAC addresses</a></li> - <li class="nav-item"><a class="nav-link text-light" href="#">Payments</a></li> - <li class="nav-item"><a class="nav-link text-light" href="log_viewer">Machine Log Viewer</a></li> - <li class="nav-item active"><a class="nav-link text-light" href="/log_view_2">Log viewer 2</a></li> - </ul> - </div> - </nav> - {% block content %}{% endblock %} - <div id="footer"> +<nav class="navbar navbar-expand-lg navbar-light bg-dark px-5" role="navigation"> + <a class="navbar-brand text-light" href="/">Hacklab Admin Panel</a> + <div class="collapse navbar-collapse" id="navbarSupportedContent"> + <ul class="navbar-nav mr-auto"> + <li class="nav-item active"><a class="nav-link text-light" href="/access_panel">Access control</a></li> + <li class="nav-item active"><a class="nav-link text-light" href="/full_log_view">Full work log viewer</a> + </li> + <li class="nav-item active"><a class="nav-link text-light" href="/devices">Devices</a></li> + <li class="nav-item active"><a class="nav-link text-light" href="/settings">Settings</a></li> + <li class="nav-item active"><a class="nav-link text-light" href="/logout">Log out</a></li> + + </ul> </div> - <footer class="footer px-2"> - <p>© Hacklab 2017</p> - </footer> - <script> - $(document).ready(function(){ - $('#tblData').dataTable({ - paging: false, - columnDefs: [ - { targets: [0], visible: false, searchable: false }, - { targets: [5], searchable: false, orderable: false } - ] - }); +</nav> +{% block content %}{% endblock %} +<div id="footer"> +</div> +<footer class="footer px-2"> + <p>© Hacklab 2017</p> +</footer> +<script> + $(document).ready(function () { + $('#tblData').dataTable({ + paging: false, }); - </script> + }); +</script> </body> </html> diff --git a/templates/log_view_2.html b/templates/log_view_2.html deleted file mode 100644 index d68651e..0000000 --- a/templates/log_view_2.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "layout.html" %} - -{% block content %} -<h1>Logs</h1> - -<div class="py-3"> - <table class="table stripe hover align-middle dataTable no-footer compact pt-3"> - <tr> - <th>Device name</th> - <th>User Name</th> - <th>Time</th> - </tr> - {% for log in logs %} - <tr> - <td>{{ log[0] }}</td> - <td>{{ log[1] }}</td> - <td>{{ log[2] }}</td> - </tr> - {% endfor %} - - </table> -</div> - - -{% endblock %} \ No newline at end of file diff --git a/templates/log_viewer.html b/templates/log_viewer.html deleted file mode 100644 index 8cb4c0d..0000000 --- a/templates/log_viewer.html +++ /dev/null @@ -1,66 +0,0 @@ -{% extends "layout.html" %} -{% block content %} - <nav class="navbar navbar-dark bg-dark"> - <div class="navbar navbar-inverse container"> - <ul class="nav navbar-nav navbar-center"> - - {% if equipment == 'mill' %} <li class="active"> {% else %} - <li> {% endif %} - <a href="/log_viewer?equipment=mill">Mill</a></li> - - {% if equipment == 'lathe' %} <li class="active"> {% else %} - <li> {% endif %} - <a href="/log_viewer?equipment=lathe">Lathe</a></li> - - {% if equipment == 'laser' %} <li class="active"> {% else %} - <li> {% endif %} - <a href="/log_viewer?equipment=laser">Laser</a></li> - - {% if equipment == 'cnc' %} <li class="active"> {% else %} - <li> {% endif %} - <a href="/log_viewer?equipment=cnc">CNC</a></li> - - {% if equipment == 'big_cnc' %} <li class="active"> {% else %} - <li> {% endif %} - <a href="/log_viewer?equipment=big_cnc">Big CNC</a></li> - - {% if equipment == 'band_saw' %} <li class="active"> {% else %} - <li> {% endif %} - <a href="/log_viewer?equipment=band_saw">Band saw</a></li> - - </ul> - - </div> - </nav> - - <div class="container"> - <div class="row"> - - <div class="col-xs-4 col-sm-offset-4 text-center"> - - <table class="table table-hover"> - <thead> - <tr> - <th class="text-center">Nick</th> - <th class="text-center">Date</th> - <th class="text-center">Time</th> - <th class="text-center">Action</th> - </tr> - </thead> - <tbody> - - {% for record in log_records %} - <tr> - {% for data in record %} - <td>{{data}}</td> - {% endfor %} - </tr> - {% endfor %} - - </tbody> - </table> - - </div> - </div> - </div> -{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..659a9b8 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,26 @@ +{% block content %} + +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Login Form</title> + <link rel="stylesheet" href="/static/css/auth.css"> +</head> +<body> +<div class="login-container"> + <h2>Login</h2> + <form class="login-form" action="#" method="post"> + <label for="username">Username:</label> + <input type="text" id="username" name="username" required> + + <label for="password">Password:</label> + <input type="password" id="password" name="password" required> + + <button type="submit">Login</button> + </form> +</div> +</body> +</html> + +{% endblock %} diff --git a/templates/macs.html b/templates/macs.html deleted file mode 100644 index ff81ad3..0000000 --- a/templates/macs.html +++ /dev/null @@ -1,50 +0,0 @@ -<style> - .select2-container .select2-selection--single { height: 32px !important} -</style> -{% extends "layout.html" %} -{% block content %} -<div class="container-fluid p-5"> - <div class="form-group"> - <input type="text" class="form-control" id="mac" placeholder="Enter MAC address" style="width:300px; max-width:100%"> - </div> - <div class="form-group"> - <select id="user_id" class="select2"> - <option value="">Select a user</option> - {% for option in user_options %} - <option value="{{option[1]}}">{{option[0]}}</option> - {% endfor %} - </select> - </div> - <button type="submit" class="btn btn-primary" onclick="addMac()" >Add MAC</button> -</div> -<div class="container-fluid px-5"> - <div class="py-3"> - <table id="tblData" class="table table-bordered"> - <thead> - <tr> - <th>Name</th> - <th>MAC</th> - </tr> - </thead> - <tbody id="table_body"> - {% for item in mac_info %} - <tr> - <td>{{item[0]}}</td> - <td> - {% for i in item[1] %} - {% if i %} - <span style="margin-right:10px">{{i}} - <a class="btn btn-danger btn-sm" onclick='deleteMac("{{i}}")' href="#"> - <i class="fa-trash-o fa" style="color: white"></i> - </a> - </span> - {% endif %} - {% endfor %} - </td> - </tr> - {% endfor %} - </tbody> - </table> - </div> -</div> -{% endblock %} diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..2e76aa9 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,37 @@ +{% extends "layout.html" %} + +{% block content %} + +<div class="container-fluid px-md-5"> + <h1 class="my-4">Settings</h1> + <div class="py-3"> + <form method="post" action="#"> + <label for="slack_token">Slack Token:</label> + <input type="text" id="slack_token" name="slack_token" required value={{ settings.slack_token }}> + + <input type="submit" value="Save slack token"> + </form> + + <form method="post" action="#"> + <label for="channel_id">Channel ID:</label> + <input type="text" id="channel_id" name="channel_id" required value={{ settings.channel_id }}> + + <input type="submit" value="Save channel id"> + </form> + + <button id="execute-button">Send backup to slack now</button> + </div> +</div> + +<script> + $(document).ready(function() { + $('#execute-button').click(function() { + $.ajax({ + type: 'POST', + url: 'send-backup-to-slack', + }); + }); + }); +</script> + +{% endblock %} diff --git a/templates/user_page.html b/templates/user_page.html new file mode 100644 index 0000000..96627af --- /dev/null +++ b/templates/user_page.html @@ -0,0 +1,65 @@ +{% extends "layout.html" %} + +{% block content %} + +<div class="container-fluid px-md-5"> + <h1 class="my-4">Profile for user : {{ full_user.user.name }}</h1> + <div> + <form method="post" action="#"> + <label for="slack_id">Slack ID:</label> + <input type="text" id="slack_id" name="slack_id" required value={{ full_user.user.slack_id }}> + + <input type="submit" value="Set user slack id"> + </form> + + <button id="execute-button">Send sent notification</button> + </div> + <div class="py-3"> + <div style="width: 100%;"> + <div style="float:left; width: 30%"> + <ul> + {% for device in full_user.devices %} + <li><a href="/device/{{device.device_id}}">{{ device.device_name }}</a></li> + {% endfor %} + </ul> + </div> + <div style="float:right; width: 70%"> + <table class="table stripe hover align-middle dataTable no-footer compact pt-3"> + <tr> + <th>Device name</th> + <th>Operation type</th> + <th>Time</th> + </tr> + {% for log in full_user.logs %} + <tr> + <td><a href="/device/{{ log.device.id }}">{{ log.device.name }}</a></td> + <td>{{ log.operation.type }}</td> + <td>{{ log.operation.time }}</td> + </tr> + {% endfor %} + </table> + </div> + </div> + <div style="clear:both"></div> + </div> + +</div> + +<script> + $(document).ready(function() { + let jsonData = { + user_key: "{{ full_user.user.key }}", + }; + $('#execute-button').click(function() { + $.ajax({ + type: 'POST', + url: 'send-test-message-to-user', + data: JSON.stringify(jsonData), + contentType: 'application/json', + }); + }); + }); +</script> + + +{% endblock %} diff --git a/txt_log_reader.py b/txt_log_reader.py deleted file mode 100644 index 18d7049..0000000 --- a/txt_log_reader.py +++ /dev/null @@ -1,52 +0,0 @@ -from flask import render_template, request -import os - -log_file_mill = "/home/pi/toolsmonitor/logs/mill.txt" -log_file_lathe = "/home/pi/toolsmonitor/logs/lathelog.txt" -log_file_laser = "/home/pi/toolsmonitor/logs/laserlog.txt" -log_file_cnc = "/home/pi/toolsmonitor/logs/cnclog.txt" -log_file_b_cnc = "/home/pi/toolsmonitor/logs/bigcnclog.txt" -log_file_b_saw = "./device_logs/bandsawlog.txt" - -def render_logs_to_html(): - equipment = request.args.get("equipment") - - if equipment == "mill": - log_file = log_file_mill - elif equipment == "lathe": - log_file = log_file_lathe - elif equipment == "laser": - log_file = log_file_laser - elif equipment == "cnc": - log_file = log_file_cnc - elif equipment == "big_cnc": - log_file = log_file_b_cnc - elif equipment == "band_saw": - log_file = log_file_b_saw - else: - # show mill logs by default - log_file = log_file_mill - equipment = "mill" - - log_records = read_log_file(log_file) - - return render_template("log_viewer.html", log_records=log_records, equipment=equipment) - -def read_log_file(log_file): - log_records = [] - - # render "we are doomed" error styled message if log file is missing - if not os.path.exists(log_file): - return ["I need", "to know", "path to", "your logs darling!"] - - # .txt to [list] heavy lifting - with open(log_file, 'r') as raw_log_source: - for record in raw_log_source: - - # drop '\n', blank space and and parse record into list - log_records.append(record.split()) - - # reorder log records from newest to oldest - log_records.reverse() - - return log_records \ No newline at end of file diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..469d103 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,6 @@ +import os + +from application import app + +if __name__ == "__main__": + app.run(host='0.0.0.0', port=os.environ.get("FLASK_SERVER_PORT"), debug=True) \ No newline at end of file