diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..72e294c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +__pycache__ +*.pyc +*.pyo +*.pyd \ No newline at end of file diff --git a/.github/workflows/python-semantic-release.yml b/.github/workflows/python-semantic-release.yml new file mode 100644 index 0000000..6f5cf96 --- /dev/null +++ b/.github/workflows/python-semantic-release.yml @@ -0,0 +1,21 @@ +name: Semantic Release + +on: + push: + branches: + - master + +jobs: + release: + runs-on: ubuntu-latest + concurrency: release + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Python Semantic Release + uses: relekang/python-semantic-release@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d990d02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# tests and coverage +*.pytest_cache +.coverage + +# database & logs +*.db +*.sqlite3 +*.log +*.sqlite + +# venv +env +venv + +# other +.DS_Store + +# sphinx docs +_build +_static +_templates + +# javascript +package-lock.json +.vscode/symbols.json + +apps/static/assets/node_modules +apps/static/assets/yarn.lock +apps/static/assets/.temp + +.env +migrations + +# Secrets +secrets.ini +ipban.ini + +# Illustrator files +*.ai + +# PyCharm +.idea + +# Uploads directory +apps/uploads/* + +# Synology Drive Client +.sync-exclude.lst \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..77ce542 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.9 + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 +ENV FLASK_APP run.py +ENV DEBUG True + +COPY requirements.txt . + +# install python dependencies +RUN pip install --upgrade pip +RUN pip install --no-cache-dir -r requirements.txt + +COPY env.sample .env + +COPY . . + +RUN flask db init +RUN flask db migrate +RUN flask db upgrade + +# gunicorn +CMD ["gunicorn", "--config", "gunicorn-cfg.py", "run:app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..77bd81c --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +![OpenALPR-Webhook](media/openalpr-webhook-preview.png) + +OpenALPR-Webhook is a self-hosted web application that accepts [Rekor Scout™](https://cloud.openalpr.com/) POST data allowing longer data retention. +It was designed with an emphasis on security to meet organization/business needs. + +- 👉 Simple clean dashboard with statistics +- 👉 Custom unlimited alerts +- 👉 Notifications via email or SMS (Twilio) +- 👉 Customize report branding +- 👉 User management and roles +- 👉 Forced camera focus (Dahua IPCs) +- 👉 IPBan (fail2ban for Flask) with [IPAbuseDB.com](https://ipabusedb.com) integration +- 👉 Webhook endpoint security + + +# 🐛 Known Bugs +- Manually requeuing jobs fail +- ~~Searching plates will only work if pagination position is on page 1~~ + - ~~This is a grid.js issue [#1314](https://github.com/grid-js/gridjs/issues/1314) [#1344](https://github.com/grid-js/gridjs/pull/1334) [#1311](https://github.com/grid-js/gridjs/issues/1311).~~ + +# ✨ Upcoming Features +- Integrate [Apprise](https://github.com/caronc/apprise) +- Enhance search functionality + - Add + - Direction + - Color + - From/To Date + - Camera + - Location + - License Plate Region +- Improve user management + - Add a password reset form for admins + - Add email notifications for new users +- Beautify email notifications with HTML + - View alerts publicly without authentication using a secure expirable routing method. +- Ability for admins to + - Export databases + - Export/import settings +- Add audit logs for each action +- Add support for 2FA/MFA + + +# Installation + +### Docker +TBD + +### Bare Server +1. apt-install python3.10 redis-server && systemctl enable redis-server && systemctl start redis-server +2. git https://github.com/mibs510/OpenALPR-Webhook +3. cd OpenALPR-Webhook +4. pip3 install -r requirements.txt +5. ./venv/Scripts/activate +6. ./app.py --host=0.0.0.0 --port=8080 + +### New Instance +Head over to the URL of your server. You will be required to login. Click on 'register' to create a super admin account. +
+After creating a super admin account, the register link will disappear as a protective measure against unauthorized account creation. +
+Accounts will need to be created manually by an administrator under Settings/Users + +# Documentation +### Dashboard +___ + +### Alerts/Blacklist +___ +### Alerts/Search +___ +### Search +___ +### Settings/Agents +___ +> Available to administrators only. +> +### Settings/Cameras +___ +> Available to administrators only. +> +### Settings/General +___ +> Available to administrators only. +> +Disable uuid_img download (pulls from agent if available real-time when viewing/printing reports) +### Settings/Maintenance/App +___ +Reinitiate cache +
+Redownload all missing uuid_imgs to db/locally +
+Trim database to keep X months of plates +
+Trim database to keep x months of high-res/uuid_imgs +
+Remove all high-res/uuid_imgs from db +> Available to administrators only. +> +### Settings/Maintenance/Redis +___ +> Available to administrators only. +> +### Settings/Profile +___ +Users can edit basic information about themselves such as name, website, email address, phone number, time zone, etc. +
+Each user has a unique `API_KEY`. The `API_KEY` key used to authorize Rekor Scout to POST data onto the webhook endpoint. +
+Administrators can set a global setting to limit which `API_KEY`'s can POST data. Refer to Settings/General. +To begin receiving data into OpenALPR-Webhook, copy your `API_KEY` into [Rekor Scout](cloud.openalpr.com) > Configuration > WebHooks Configuration > Add New Webhook > Custom Data +
+`API_KEY: vvvvvvvv-wwww-xxxx-yyyy-zzzzzzzzzzzz` +
+Be sure to fill in all other fields such as Destination URL, Description, check Send All Plate Reads, check Send Matching Alerts, and check Send Reads missing plate. + +### Settings/Notifications +___ +> Available to administrators only. +> +### Settings/Users +___ +> Available to administrators only. +> +Administrators can create users, edit users, change user roles, and suspend user accounts. +
+Once an account has been created, it cannot be deleted. This is to preserve accounts and their API tokens for audit +purposes. \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..7aeca84 --- /dev/null +++ b/app.py @@ -0,0 +1,37 @@ +from apps import create_app, db +from apps.config import config_dict +from flask_script import Manager +from flask_migrate import Migrate, MigrateCommand +from flask_minify import Minify +import os + +# WARNING: Don't run with debug turned on in production! +DEBUG = os.getenv('DEBUG', 'False') == 'True' + +# The configuration +get_config_mode = 'Debug' if DEBUG else 'Production' + +# Load the configuration using the default values +app_config = config_dict[get_config_mode.capitalize()] + +app = create_app(app_config) +app.config['ENV'] = get_config_mode.capitalize() +Migrate(app, db) + +# DB Migration +manager = Manager(app) +manager.add_command('db', MigrateCommand) + +if not DEBUG: + Minify(app=app, html=True, js=False, cssless=False) + + +if DEBUG: + app.logger.info('DEBUG = ' + str(DEBUG)) + app.logger.info('Page Compression = ' + 'FALSE' if DEBUG else 'TRUE') + app.logger.info('DBMS = ' + app_config.SQLALCHEMY_DATABASE_URI) + + +if __name__ == "__main__": + with app.app_context(): + app.run(host="0.0.0.0", port=8080) diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..d5feaef --- /dev/null +++ b/apps/__init__.py @@ -0,0 +1,102 @@ +import platform +import subprocess +from datetime import datetime + +from flask_ipban import IpBan +from flask_migrate import Migrate +from redis import Redis +from rq import Queue +from flask import Flask +from flask_login import LoginManager +from flask_sqlalchemy import SQLAlchemy, declarative_base +from importlib import import_module +from flask_mail import Mail + +import version +from apps.alpr.ipban_config import IPBanConfig +from apps.alpr.enums import WorkerType + +mail = Mail() +db = SQLAlchemy() +migrate = Migrate() +default_q = Queue(WorkerType.General.value, connection=Redis()) +camera_q = Queue(WorkerType.Camera.value, connection=Redis()) +Base = declarative_base() +login_manager = LoginManager() +ip_ban_config = IPBanConfig() +ip_ban = IpBan(ban_count=ip_ban_config.ban_count, ban_seconds=ip_ban_config.ban_seconds, persist=ip_ban_config.persist, + record_dir=ip_ban_config.record_dir, ip_header=ip_ban_config.ip_header) + + +def register_extensions(app): + db.init_app(app) + login_manager.init_app(app) + + +def register_blueprints(app): + for module_name in ('api', 'authentication', 'alpr.routes.alert', 'alpr.routes.alerts', + 'alpr.routes.alerts.custom', 'alpr.routes.alerts.rekor', 'alpr.routes.capture', + 'alpr.routes.search', 'alpr.routes.settings', 'alpr.routes.settings.agents', + 'alpr.routes.settings.cameras', 'alpr.routes.settings.general', + 'alpr.routes.settings.maintenance', 'alpr.routes.settings.maintenance.rq_dashboard', + 'alpr.routes.settings.notifications', 'alpr.routes.settings.profile', + 'alpr.routes.settings.users', 'home'): + module = import_module('apps.{}.routes'.format(module_name)) + app.register_blueprint(module.blueprint) + + +def configure_database(app): + @app.context_processor + def inject_global_vars(): + return dict(app_version=version.__version__) + + @app.before_first_request + def initialize_databases(): + # db.create_all() + pass + + @app.before_first_request + def initialize_settings(): + # Initiate cache when needed + from apps.alpr.models.cache import Cache + now = datetime.now() + last_year = now.year - 1 + this_year = now.year + next_year = now.year + 1 + + Cache.filter_by_year(last_year) + Cache.filter_by_year(this_year) + Cache.filter_by_year(next_year) + + # Create default settings when needed + from apps.alpr.models.settings import GeneralSettings + settings = GeneralSettings.get_settings() + + if settings is None: + settings = GeneralSettings() + settings.save() + + # Start redis workers on Linux only + if platform.system() == "Linux": + from apps.alpr.models.settings import CameraSettings + camera_workers = len(CameraSettings.get_all_enabled()) + workers_cmd = subprocess.run(["./workers.py", "-c", camera_workers, "-g", camera_workers]) + print("workers_cmd.returncode = {}".format(workers_cmd.returncode)) + print("workers_cmd.stdout = {}".format(workers_cmd.stdout)) + print("workers_cmd.stderr = {}".format(workers_cmd.stderr)) + + +def create_app(config) -> Flask: + app = Flask(__name__) + app.config.from_object(config) + mail.init_app(app) + ip_ban.init_app(app) + register_extensions(app) + register_blueprints(app) + db.init_app(app) + migrate.init_app(app, db, render_as_batch=True) + configure_database(app) + + with app.app_context(): + db.create_all() + return app diff --git a/apps/alpr/__init__.py b/apps/alpr/__init__.py new file mode 100644 index 0000000..7964c2f --- /dev/null +++ b/apps/alpr/__init__.py @@ -0,0 +1,11 @@ +# Database +__alpr_alert_db__ = "apps/db/alpr_alert.db" +__alpr_group_db__ = "apps/db/alpr_group.db" +__cache_db__ = "apps/db/cache.db" +__vehicle_db__ = "apps/db/vehicle.db" + +# Json +__alpr_alert_json__ = "apps/db/alpr_alert.json" +__alpr_group_json__ = "apps/db/alpr_group.json" +__cache_json__ = "apps/db/cache.json" +__vehicle_json__ = "apps/db/vehicle.json" \ No newline at end of file diff --git a/apps/alpr/beautify.py b/apps/alpr/beautify.py new file mode 100644 index 0000000..9fd2916 --- /dev/null +++ b/apps/alpr/beautify.py @@ -0,0 +1,106 @@ +import json +import time + +import pycountry +from markupsafe import Markup + + +def datetime(epoch: int) -> str: + """ Convert time. Example: 1655785225334 (epoch) => Sep 29 01:22:14 PM """ + return time.strftime('%b %d %Y %I:%M:%S %p', time.localtime(epoch / 1000)) + + +def round_percentage(percent, symbol=True) -> str: + """ Round a percentage to two decimal places. Example: 93.25465393 => 93.25% """ + if symbol: + return "{}%".format(round(float(percent), 2)) + else: + return "{}".format(round(float(percent), 2)) + + +def direction(degree: float) -> str: + degree = float(degree) + """ Converts a degree (0-360) to a graphical direction icon in html. Example: 261.566467285156 => mdi-arrow-down""" + class_tag = "mdi-help" + + if 0 <= degree <= 10 or 350 < degree <= 360: + class_tag = "up" + elif 10 < degree <= 80: + class_tag = "top-right" + elif 80 < degree <= 100: + class_tag = "right" + elif 100 < degree <= 170: + class_tag = "bottom-right" + elif 170 < degree <= 190: + class_tag = "down" + elif 190 < degree <= 260: + class_tag = "bottom-left" + elif 260 < degree <= 280: + class_tag = "left" + elif 280 < degree <= 350: + class_tag = "top-left" + + return class_tag + + +def country(iso3166: str) -> str: + return "N/A" if iso3166 == "N/A" else pycountry.subdivisions.get(code=iso3166).name + + +def get_flag_uri(iso3166: str) -> str: + uri = "/img/flags" + iso3166 = str(iso3166) + + if iso3166.split("-")[0] == 'us': + return uri + "/us/" + iso3166.split("-")[1] + ".svg" + else: + return uri + "/4x3/" + iso3166.split("-")[0] + ".svg" + + +# Thanks to rtaft @stackoverflow.com +# https://stackoverflow.com/a/45846841 +def human_format(num: int) -> str: + num = float('{:.3g}'.format(num)) + magnitude = 0 + while abs(num) >= 1000: + magnitude += 1 + num /= 1000.0 + return '{}{}'.format('{:f}'.format(num).rstrip('0').rstrip('.'), ['', 'K', 'M', 'B', 'T'][magnitude]) + + +# Thanks to @hostingutilities.com at stackoverflow.com +# https://stackoverflow.com/a/43750422 +def human_size(bytes: int, units=[' bytes','KB','MB','GB','TB', 'PB', 'EB']) -> str: + """ Returns a human readable string representation of bytes """ + return str(bytes) + units[0] if bytes < 1024 else human_size(bytes >> 10, units[1:]) + + +def get_negative_mtm_svg_html_arrow() -> Markup: + return Markup('') + + +def get_positive_mtm_svg_html_arrow() -> Markup: + return Markup('') + + +def name(ugly_name: str) -> str: + stage1 = ugly_name.replace("_", " ").replace("-", " ") + words = stage1.split(" ") + stage2 = "" + i = 0 + for word in words: + if word.__len__() <= 3 and word != "yes" and word != "no": + stage2 += word.upper() + " " + else: + stage2 += word.capitalize() + " " + return stage2.rstrip() + + +def print_json(json_obj: str) -> None: + print(json.dumps(json_obj, ensure_ascii=False, indent=4)) diff --git a/apps/alpr/cache.py b/apps/alpr/cache.py new file mode 100644 index 0000000..f7eedc4 --- /dev/null +++ b/apps/alpr/cache.py @@ -0,0 +1,210 @@ +import json + +from unqlite import UnQLite + +import apps.alpr as alpr +import apps.alpr.get as Get +import apps.alpr.util as util + +from datetime import datetime + + +class Cache: + def __init__(self): + cache_db = alpr.__cache_db__ + cache = UnQLite(cache_db) + self.collection = cache.collection("cache") + + self.now = datetime.now() + self.month = self.now.month + self.year = self.now.year + + def append_year(self, year) -> None: + cache_json_file = open(alpr.__cache_json__) + cache_json = json.load(cache_json_file) + cache_json['year'] = year + + self.collection.store(json.loads(json.dumps(cache_json))) + + def check_year(self, year) -> None: + year_in_cache = False + for years in self.collection.all(): + if years['year'] == year: + year_in_cache = True + + if not year_in_cache: + self.append_year(year) + + def get_alert_count(self, year, month) -> int: + for row in self.collection: + if row['year'] == year: + return row['month'][month - 1]['alerts'] + + def get_all_time_count(self, key) -> int: + count = 0 + for row in self.collection: + count += row[key] + + return count + + def get_license_plate_count(self, year, month) -> int: + for row in self.collection: + if row['year'] == year: + return row['month'][month - 1]['license_plates_captured'] + + def get_regions(self, year, month) -> {}: + for row in self.collection: + if row['year'] == year: + return row['month'][month - 1]['regions'] + + def get_region_count(self, year, month, region) -> int: + if region == "N/A": + return 0 + for row in self.collection: + if row['year'] == year: + count = row['month'][month - 1]['regions'].get(region) + if count is not None: + return int((row['month'][month - 1]['regions'].get(region))) + else: + return 0 + + def get_top_region(self, year, month) -> str: + for row in self.collection: + if row['year'] == year: + if len(list(row['month'][month - 1]['regions'])) != 0: + return str(list(row['month'][month - 1]['regions'].keys())[0]) + else: + return "N/A" + + def get_top_region_count(self, year, month) -> int: + for row in self.collection: + if row['year'] == year: + if len(list(row['month'][month - 1]['regions'])) != 0: + return int(list(row['month'][month - 1]['regions'].values())[0]) + else: + return 0 + + def init(self) -> str: + alpr_group_db = alpr.__alpr_group_db__ + alpr_groups = UnQLite(alpr_group_db) + alpr_group_collection = alpr_groups.collection("alpr_group") + + alpr_alert_db = alpr.__alpr_alert_db__ + alpr_alerts = UnQLite(alpr_alert_db) + alpr_alert_collection = alpr_alerts.collection("alpr_alert") + + vehicle_db = alpr.__vehicle_db__ + vehicles = UnQLite(vehicle_db) + vehicle_collection = vehicles.collection("vehicle") + + # Get the number of plates from the previous 11 months + # Monday - Sunday + # now: 2023-01-16 10:02:43.651522 + # start_of_week: 2023-01-16 00:00:00 + # end_of_week: 2023-01-22 00:00:00 + now = datetime.now() + month = now.month + this_year = now.year + + for i in range(12): + start_of_month = datetime(year=this_year, month=month, day=1) + end_of_month = Get.Get().last_day_of_month(datetime(year=this_year, month=month, day=1)) + epoch_start_of_month = start_of_month.timestamp() + epoch_end_of_month = end_of_month.timestamp() + + # License Plates + this_month_license_plates = alpr_group_collection.filter( + lambda start: epoch_start_of_month <= start['epoch_start'] / 1000 <= epoch_end_of_month) + this_month_license_plate_count = 0 if this_month_license_plates is None else len(this_month_license_plates) + + # Alerts + this_month_alerts = alpr_alert_collection.filter( + lambda start: epoch_start_of_month <= start['epoch_time'] / 1000 <= epoch_end_of_month) + this_month_alert_count = 0 if this_month_alerts is None else len(this_month_alerts) + + # Vehicles + this_month_vehicles = vehicle_collection.filter( + lambda start: epoch_start_of_month <= start['epoch_start'] / 1000 <= epoch_end_of_month) + this_month_vehicle_count = 0 if this_month_vehicles is None else len(this_month_vehicles) + + # All Regions + this_month_regions = Get.Get().all_regions(this_month_license_plates) + + # All camera + cameras = Get.Get().all_cameras(this_month_license_plates) + + # Populate cache + for year in self.collection: + if year['year'] == this_year: + year['all_time_plates_captured'] += this_month_license_plate_count + year['all_time_alerts'] += this_month_alert_count + year['all_time_vehicles'] += this_month_vehicle_count + year['month'][month - 1]['license_plates_captured'] = this_month_license_plate_count + year['month'][month - 1]['alerts'] = this_month_alert_count + year['month'][month - 1]['regions'] = this_month_regions + year['month'][month - 1]['camera'] = cameras + self.collection.update(year['__id'], year) + + # Move to previous month + month = month - 1 + # Move to previous year when needed + if month == 0: + this_year = this_year - 1 + month = 12 + + util.save_to_json(self.collection.all(), "cache_exported") + return "Cache initiated!" + + def increase_dict_value_count(self, dict_index, key) -> {}: + for row in self.collection: + if row['year'] == self.year: + keys_values = dict(row['year']['month'][self.month - 1][dict_index]) + if key in keys_values.keys(): + # Increase the counter/value if the key is already in the dictionary + keys_values[key] += 1 + else: + # Add another key value pair to the dictionary if the pair does not already exist + keys_values.update({key: 1}) + + # Sort the dictionary from greatest to least + return dict(sorted(keys_values.items(), key=lambda count: count[1], reverse=True)) + + def init_db(self) -> None: + now = datetime.now() + last_year = now.year - 1 + this_year = now.year + next_year = now.year + 1 + + self.collection.create() + self.append_year(last_year) + self.append_year(this_year) + self.append_year(next_year) + util.save_to_json(self.collection.all(), "cache_exported") + + def update(self, record) -> None: + # Check cache if it has somewhere to store a new year + this_year = self.year + self.check_year(this_year) + + # Populate cache + for year in self.collection: + if year['year'] == this_year: + # Counters + if record['data_type'] == "alpr_group": + year['all_time_plates_captured'] += 1 + year['month'][self.month - 1]['license_plates_captured'] += 1 + # Objects + year['month'][self.month - 1]['camera'] = \ + self.increase_dict_value_count("camera", record['web_server_config']['camera_label']) + year['month'][self.month - 1]['regions'] = \ + self.increase_dict_value_count("regions", record['best_region']) + elif record['data_type'] == "alpr_alert": + year['all_time_alerts'] += 1 + year['month'][self.month - 1]['alerts'] += 1 + elif record['data_type'] == "vehicle": + year['all_time_vehicles'] += 1 + year['month'][self.month - 1]['vehicles'] += 1 + + self.collection.update(year['__id'], year) + + return diff --git a/apps/alpr/enums.py b/apps/alpr/enums.py new file mode 100644 index 0000000..d82dfa2 --- /dev/null +++ b/apps/alpr/enums.py @@ -0,0 +1,34 @@ +import enum + + +class AccountStatus(enum.Enum): + NON_ACTIVATED = 0 + ACTIVATED = 1 + + +class ChartType(enum.Enum): + ALERT_CHART = "alert-chart" + PLATES_CAPTURED_CHART = "plates-captured-chart" + TOP_REGION_CHART = "top-region-chart" + + +class DataType(enum.Enum): + ALERT = "alpr_alert" + GROUP = "alpr_group" + VEHICLE = "vehicle" + + +class UserRole(enum.Enum): + ADMIN = "ADMIN" + REGULAR = "NONADMIN" + + +class AccountVerified(enum.Enum): + NON_VERIFIED = 0 + VERIFIED = 1 + + +class WorkerType(enum.Enum): + # worker type = queue name + Camera = 'cameras' + General = 'default' diff --git a/apps/alpr/get.py b/apps/alpr/get.py new file mode 100644 index 0000000..3098d13 --- /dev/null +++ b/apps/alpr/get.py @@ -0,0 +1,334 @@ +import os.path +import platform +from datetime import datetime, timedelta + +from markupsafe import Markup +from unqlite import UnQLite + +import apps.alpr as alpr +import apps.alpr.beautify as beautify +import apps.alpr.cache as Cache +import apps.alpr.util as util + + +class Get: + now = datetime.now() + month = now.month + year = now.year + + def __init__(self): + self.cache = Cache.Cache() + + def all_cameras(self, filtered_collection) -> {}: + if filtered_collection is None: + return {} + + cameras = [] + + # Add every camera for that month onto a list + for record in filtered_collection: + cameras.append(record['web_server_config']['camera_label']) + + # Go through each region and get a count + dictionary = {} + for camera in cameras: + dictionary[camera] = cameras.count(camera) + + # Sort the dictionary from greatest to least + return dict(sorted(dictionary.items(), key=lambda count: count[1], reverse=True)) + + def all_dbs_file_sizes(self) -> str: + return beautify.human_size(os.path.getsize(alpr.__alpr_alert_db__) + os.path.getsize(alpr.__alpr_group_db__) + + os.path.getsize(alpr.__vehicle_db__)) + + def chart_series(self, chart) -> []: + series = [] + + this_year = self.year + # Move to previous month + last_month = self.month - 1 + # Move to previous year when needed + if last_month == 0: + this_year -= 1 + last_month = 12 + + for i in range(12): + if chart == "plates-captured-chart" or chart == "top-region-chart": + # License Plates Captured + this_month_license_plate_count = self.cache.get_license_plate_count(this_year, last_month) + + if chart == "plates-captured-chart": + series.append(this_month_license_plate_count) + elif chart == "top-region-chart": + # Top Region + this_month_top_region_count = self.cache.get_top_region_count(this_year, last_month) + series.append(this_month_top_region_count) + + elif chart == "alert-chart": + # Alerts + this_month_alert_count = self.cache.get_alert_count(this_year, last_month) + series.append(this_month_alert_count) + + # Move to previous month + last_month -= 1 + # Move to previous year when needed + if last_month == 0: + this_year -= 1 + last_month = 12 + + return Markup(list(reversed(series))) + + def chart_labels(self, chart=None) -> []: + series = [] + + this_year = self.year + # Move to previous month + last_month = self.month - 1 + + for i in range(12): + start_of_month = datetime(year=this_year, month=last_month, day=1) + + if chart == "top-region-chart": + pretty_region = beautify.country(self.cache.get_top_region(this_year, last_month)) + series.append("{} - {}".format(start_of_month.strftime("%b %Y"), pretty_region)) + else: + series.append(start_of_month.strftime("%b %Y")) + + # Move to previous month + last_month -= 1 + # Move to previous year when needed + if last_month == 0: + this_year -= 1 + last_month = 12 + + return Markup(list(reversed(series))) + + def collection(self, database="alpr_group") -> {}: + db = alpr.__alpr_group_db__ + table = "alpr_group" + + if database == "alert": + db = alpr.__alpr_alert_db__ + table = "alpr_alert" + elif database == "cache": + db = alpr.__cache_db__ + table = "cache" + elif database == "vehicle": + db = alpr.__vehicle_db__ + table = "vehicle" + + db_connection = UnQLite(db) + return db_connection.collection(table) + + # Thanks to augustomen @stackoverflow.com + # https://stackoverflow.com/a/13565185 + def last_day_of_month(self, any_day): + # The day 28 exists in every month. 4 days later, it's always next month + next_month = any_day.replace(day=28) + timedelta(days=4) + # subtracting the number of the current day brings us back one month + return next_month - timedelta(days=next_month.day) + + def number_of_records(self) -> str: + return beautify.human_format(self.cache.get_all_time_count("all_time_plates_captured") + + self.cache.get_all_time_count("all_time_alerts") + + self.cache.get_all_time_count("all_time_vehicles")) + + def quick_stats(self) -> {}: + response = {} + + # Get the number of plates and alerts for this month + start_of_month = datetime.today().replace(day=1) + end_of_month = self.last_day_of_month(self.now) + + if platform.system() == "Windows": + response['start_of_month_pretty'] = start_of_month.strftime("%b %e") + response['end_of_month_pretty'] = end_of_month.strftime("%b %e") + else: + response['start_of_month_pretty'] = start_of_month.strftime("%b %-d") + response['end_of_month_pretty'] = end_of_month.strftime("%b %-d") + + this_year = self.year + # Move to previous month + last_month = self.month - 1 + # Move to previous year when needed + if last_month == 0: + this_year -= 1 + last_month = 12 + + this_month_alert_count = self.cache.get_alert_count(self.year, self.month) + this_month_license_plate_count = self.cache.get_license_plate_count(self.year, self.month) + this_month_top_region = self.cache.get_top_region(self.year, self.month) + # print("this_month_top_region = {}".format(this_month_top_region)) + this_month_top_region_count = self.cache.get_top_region_count(self.year, self.month) + # print("this_month_top_region_count = {}".format(this_month_top_region_count)) + + response['this_month_alert_count'] = this_month_alert_count + response['this_month_license_plate_count'] = this_month_license_plate_count + response['this_month_top_region'] = beautify.country(this_month_top_region) + response['this_month_top_region_count'] = this_month_top_region_count + + last_month_alert_count = self.cache.get_alert_count(this_year, last_month) + last_month_license_plate_count = self.cache.get_license_plate_count(this_year, last_month) + last_month_top_region = self.cache.get_top_region(this_year, last_month) + # print("last_month_top_region = {}".format(last_month_top_region)) + last_month_top_region_count = self.cache.get_region_count(this_year, last_month, this_month_top_region) + # print("last_month_top_region_count = {}".format(last_month_top_region_count)) + + response['last_month_alert_count'] = last_month_alert_count + response['last_month_license_plate_count'] = last_month_license_plate_count + response['last_month_top_region'] = beautify.country(last_month_top_region) + response['last_month_top_region_count'] = last_month_top_region_count + + # Calculate Month-To-Month for the number of plates captured + if last_month_license_plate_count != 0: + mtm_license_plates_percent = ((this_month_license_plate_count / last_month_license_plate_count) - 1) * 100 + mtm_license_plates_percent = round(mtm_license_plates_percent, 1) + # Determine the color/class of the percentage + if mtm_license_plates_percent < 0: + response['this_month_license_plates_mtm_class'] = "text-danger" + response['this_month_license_plates_mtm_svg_html_arrow'] = beautify.get_negative_mtm_svg_html_arrow() + elif mtm_license_plates_percent == 0: + response['this_month_license_plates_mtm_class'] = "" + elif mtm_license_plates_percent > 0: + response['this_month_license_plates_mtm_class'] = "text-success" + response['this_month_license_plates_mtm_svg_html_arrow'] = beautify.get_positive_mtm_svg_html_arrow() + elif last_month_license_plate_count == 0 and this_month_license_plate_count != 0: + mtm_license_plates_percent = 100 + response['this_month_license_plates_mtm_class'] = "text-success" + response['this_month_license_plates_mtm_svg_html_arrow'] = beautify.get_positive_mtm_svg_html_arrow() + else: + mtm_license_plates_percent = 0 + response['this_month_license_plates_mtm_class'] = "" + response['this_month_license_plates_mtm_svg_html_arrow'] = "" + + response["this_month_license_plates_mtm_percent"] = mtm_license_plates_percent + + # Calculate Month-To-Month for the number of alerts captured + if last_month_alert_count != 0: + mtm_alerts_percent = ((this_month_alert_count / last_month_alert_count) - 1) * 100 + mtm_alerts_percent = round(mtm_alerts_percent, 1) + # Determine the color/class of the percentage + if mtm_alerts_percent < 0: + response['this_month_alerts_mtm_class'] = "text-danger" + response['this_month_alerts_mtm_svg_html_arrow'] = beautify.get_negative_mtm_svg_html_arrow() + elif mtm_alerts_percent == 0: + response['this_month_alerts_mtm_class'] = "" + elif mtm_alerts_percent > 0: + response['this_month_alerts_mtm_class'] = "text-success" + response['this_month_alerts_mtm_svg_html_arrow'] = beautify.get_positive_mtm_svg_html_arrow() + elif last_month_alert_count == 0 and this_month_alert_count != 0: + mtm_alerts_percent = 100 + response['this_month_alerts_mtm_class'] = "text-success" + response['this_month_alerts_mtm_svg_html_arrow'] = beautify.get_positive_mtm_svg_html_arrow() + else: + mtm_alerts_percent = 0 + response['this_month_alerts_mtm_class'] = "" + response['this_month_alerts_mtm_svg_html_arrow'] = "" + + response["this_month_alerts_mtm_percent"] = mtm_alerts_percent + + # Calculate Month-To-Month for the top region + if last_month_top_region != "N/A" and this_month_top_region != "N/A": + mtm_region_percent = ((this_month_top_region_count / last_month_top_region_count) - 1) * 100 + mtm_region_percent = round(mtm_region_percent, 1) + # Determine the color/class of the percentage + if mtm_region_percent < 0: + response['this_month_top_region_mtm_class'] = "text-danger" + response['this_month_top_region_mtm_svg_html_arrow'] = beautify.get_negative_mtm_svg_html_arrow() + elif mtm_region_percent == 0: + response['this_month_top_region_mtm_class'] = "" + elif mtm_region_percent > 0: + response['this_month_top_region_mtm_class'] = "text-success" + response['this_month_top_region_mtm_svg_html_arrow'] = beautify.get_positive_mtm_svg_html_arrow() + elif last_month_top_region_count == 0 and this_month_top_region_count != 0: + mtm_region_percent = 100 + response['this_month_top_region_mtm_class'] = "text-success" + response['this_month_top_region_mtm_svg_html_arrow'] = beautify.get_positive_mtm_svg_html_arrow() + else: + mtm_region_percent = 0 + response['this_month_top_region_mtm_class'] = "" + response['this_month_top_region_mtm_svg_html_arrow'] = "" + + response["this_month_top_region_mtm_percent"] = mtm_region_percent + + return response + + def record(self, db="group", n=0) -> []: + collections = self.collection(db) + return_obj = [] + start = len(collections) - 1 + finish = 0 + + # Iterator limit is set to the number of records + if n != 0: + finish = start - n + + for i in range(start, finish, -1): + return_obj.append(collections[i]) + + return return_obj + + def regions(self, n=0) -> []: + rgns = [] + + this_year = self.year + # Move to previous month + last_month = self.month - 1 + # Move to previous year when needed + if last_month == 0: + this_year -= 1 + last_month = 12 + + last_month_license_plate_count = self.cache.get_license_plate_count(this_year, last_month) + last_month_regions = self.cache.get_regions(this_year, last_month) + + for region in last_month_regions: + rgns.append({"name": beautify.country(region), + "count": last_month_regions[region], + "percent": (beautify.round_percentage((last_month_regions[region]/ + last_month_license_plate_count) * 100, symbol=False)), + "total": last_month_license_plate_count, "flag_uri": beautify.get_flag_uri(region)}) + + if n != 0: + return rgns[:n] + else: + return rgns + + def all_regions(self, filtered_collection) -> {}: + if filtered_collection is None: + return {} + + regions = [] + + # Add every region for that month onto a list + for record in filtered_collection: + regions.append(record['best_region']) + + # Go through each region and get a count + dictionary = {} + for region in regions: + dictionary[region] = regions.count(region) + + # Sort the dictionary from greatest to least + return dict(sorted(dictionary.items(), key=lambda count: count[1], reverse=True)) + + def us_map_series(self) -> []: + this_year = self.year + # Move to previous month + last_month = self.month - 1 + # Move to previous year when needed + if last_month == 0: + this_year -= 1 + last_month = 12 + + regions = self.cache.get_regions(this_year, last_month) + us_regions = [] + + if len(regions) == 0: + return Markup([]) + + for key in regions: + if str(key).split("-")[0] == "us": + us_regions.append([str(key).split("-")[1].upper(), regions[key]]) + + return Markup(us_regions) diff --git a/apps/alpr/ipban/__init__.py b/apps/alpr/ipban/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/alpr/ipban_config.py b/apps/alpr/ipban_config.py new file mode 100644 index 0000000..baab3a2 --- /dev/null +++ b/apps/alpr/ipban_config.py @@ -0,0 +1,60 @@ +import configparser +from os.path import exists + + +class IPBanConfig: + ban_count = 20 + ban_seconds = 86400 + persist = False + ip_header = "" + abuse_IPDB_config_report = False + abuse_IPDB_config_load = False + abuse_IPDB_config_key = "" + record_dir = "log/" + + config = configparser.ConfigParser() + + def __init__(self): + # Set up ipban ini file with defaults if it doesn't exist + if not exists("ipban.ini"): + self.save() + + self.read() + + def get_settings(self) -> {}: + persist = True if self.persist == "True" else False + abuse_IPDB_config_report = True if self.abuse_IPDB_config_report == "True" else False + abuse_IPDB_config_load = True if self.abuse_IPDB_config_load == "True" else False + + return { + 'ban_count': self.ban_count, 'ban_seconds': self.ban_seconds, 'persist': persist, + 'ip_header': self.ip_header, 'abuse_IPDB_config_report': abuse_IPDB_config_report, + 'abuse_IPDB_config_load': abuse_IPDB_config_load, + 'abuse_IPDB_config_key': self.abuse_IPDB_config_key + } + + def read(self) -> None: + # Create a config obj + self.config = configparser.ConfigParser() + # Read from the ini file + self.config.read("ipban.ini") + + # (Re)Populate this obj + self.ban_count = self.config['ipban']['ban_count'] + self.ban_seconds = self.config['ipban']['ban_seconds'] + self.persist = self.config['ipban']['persist'] + self.ip_header = self.config['ipban']['ip_header'] + self.abuse_IPDB_config_report = self.config['ipban']['abuse_IPDB_config_report'] + self.abuse_IPDB_config_load = self.config['ipban']['abuse_IPDB_config_load'] + self.abuse_IPDB_config_key = self.config['ipban']['abuse_IPDB_config_key'] + + def save(self) -> None: + self.config['ipban'] = {'ban_count': self.ban_count, 'ban_seconds': self.ban_seconds, 'persist': self.persist, + 'ip_header': self.ip_header, + 'abuse_IPDB_config_report': self.abuse_IPDB_config_report, + 'abuse_IPDB_config_load': self.abuse_IPDB_config_load, + 'abuse_IPDB_config_key': self.abuse_IPDB_config_key, + 'record_dir': self.record_dir + } + with open("ipban.ini", 'w', encoding='utf-8') as ini: + self.config.write(ini) diff --git a/apps/alpr/models/__init__.py b/apps/alpr/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/alpr/models/alpr_alert.py b/apps/alpr/models/alpr_alert.py new file mode 100644 index 0000000..c56bb68 --- /dev/null +++ b/apps/alpr/models/alpr_alert.py @@ -0,0 +1,133 @@ +from datetime import datetime + +from flask_login import current_user + +from apps import db, helpers +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.mutable import MutableDict + +from apps.alpr import beautify +from apps.exceptions.exception import InvalidUsage + + +class ALPRAlert(db.Model): + __bind_key__ = 'alpr_alert' + __tablename__ = 'alpr_alert' + + id = db.Column(db.Integer, primary_key=True) + data_type = db.Column(db.String, default="alpr_alert") + version = db.Column(db.Integer) + epoch_time = db.Column(db.Integer) + agent_uid = db.Column(db.String) + alert_list = db.Column(db.String) + alert_list_id = db.Column(db.Integer) + site_name = db.Column(db.String) + camera_name = db.Column(db.String) + camera_number = db.Column(db.Integer) + plate_number = db.Column(db.String) + description = db.Column(db.String) + list_type = db.Column(db.String) + group = db.Column(MutableDict.as_mutable(db.JSON)) + custom_data = db.Column(MutableDict.as_mutable(db.JSON)) + + # Custom + best_confidence_percent = db.Column(db.String) + travel_direction_class_tag = db.Column(db.String) + uuid_jpg = db.Column(db.String) + + start_of_month_timestamp = datetime.now().timestamp() + end_of_month_timestamp = datetime.now().timestamp() + + def __init__(self, **kwargs): + super(ALPRAlert, self).__init__(**kwargs) + self.start_of_month_timestamp = datetime.now().timestamp() + self.end_of_month_timestamp = datetime.now().timestamp() + self.collection = [] + + @classmethod + def filter_by_id(cls, _id: int) -> "ALPRAlert": + return cls.query.filter_by(id=_id).first() + + @classmethod + def filter_by_id_and_beautify(cls, _id: int) -> {}: + record = cls.filter_by_id(_id) + + if record: + dt = helpers.Timezone(current_user, msecs=True) + return { + 'api_key': str(record.custom_data['API_KEY'][-4:]).upper(), + 'description': record.description, + 'alert_list_id': record.alert_list_id, + 'list_type': record.list_type, + 'best_plate_number': record.plate_number, + 'uuid_jpg': record.uuid_jpg, + 'overview_jpeg': record.group['overview_jpeg'], + 'vehicle_crop_jpeg': record.group['vehicle_crop_jpeg'], + 'plate_crop_jpeg': record.group['best_plate']['plate_crop_jpeg'], + 'agent_label': record.site_name, + 'agent_uid': record.agent_uid, + 'agent_version': record.group['agent_version'], + 'agent_type': record.group['agent_type'], + 'camera_label': record.camera_name, + 'camera_id': record.camera_number, + 'gps_latitude': record.group['gps_latitude'], + 'gps_longitude': record.group['gps_longitude'], + 'country': beautify.name(record.group['country']), + 'id': record.id, + 'epoch_time': record.epoch_time, + 'epoch_datetime': dt.astimezone(record.epoch_time), + 'best_confidence_percent': record.best_confidence_percent, + 'best_region': beautify.country(record.group['best_region']), + 'travel_direction_class_tag': record.travel_direction_class_tag, + 'travel_direction': round(float(record.group['travel_direction']), 0), + 'vehicle_color_name': beautify.name(record.group['vehicle']['color'][0]['name']), + 'vehicle_color_confidence': beautify.round_percentage( + record.group['vehicle']['color'][0]['confidence']), + 'vehicle_year_name': record.group['vehicle']['year'][0]['name'], + 'vehicle_year_confidence': beautify.round_percentage(record.group['vehicle']['year'][0]['confidence']), + 'vehicle_make_name': beautify.name(record.group['vehicle']['make'][0]['name']), + 'vehicle_make_confidence': beautify.round_percentage(record.group['vehicle']['make'][0]['confidence']), + 'vehicle_make_model_name': beautify.name(record.group['vehicle']['make_model'][0]['name']), + 'vehicle_make_model_confidence': beautify.round_percentage( + record.group['vehicle']['make_model'][0]['confidence']), + 'vehicle_body_type_name': beautify.name(record.group['vehicle']['body_type'][0]['name']), + 'vehicle_body_type_confidence': beautify.round_percentage( + record.group['vehicle']['body_type'][0]['confidence']) + } + + else: + return None + + def filter_epoch_time(self) -> "ALPRAlert": + self.collection = self.query.filter((ALPRAlert.epoch_time / 1000) >= self.start_of_month_timestamp).filter( + (ALPRAlert.epoch_time / 1000) <= self.end_of_month_timestamp).all() + return self.collection + + def get_dashboard_records(self, n=3) -> []: + records = self.query.order_by(ALPRAlert.id.desc()).limit(n) + modified_records = [] + dt = helpers.Timezone(current_user) + + for record in records: + modified_records.append({ + 'id': record.id, + 'month': dt.month(record.epoch_time), + 'day': dt.day(record.epoch_time), + 'plate_number': record.plate_number, + 'list_type': record.list_type, + 'epoch_time_datetime': dt.astimezone(record.epoch_time), + 'site_name': record.site_name, + 'camera_name': record.camera_name + }) + + return modified_records + + def save(self) -> None: + try: + db.session.add(self) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) diff --git a/apps/alpr/models/alpr_group.py b/apps/alpr/models/alpr_group.py new file mode 100644 index 0000000..ceed530 --- /dev/null +++ b/apps/alpr/models/alpr_group.py @@ -0,0 +1,251 @@ +import logging +from datetime import datetime + +from flask_login import current_user + +from apps import db, helpers +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.mutable import MutableDict, MutableList + +from apps.alpr import beautify +from apps.exceptions.exception import InvalidUsage + + +class ALPRGroup(db.Model): + __bind_key__ = 'alpr_group' + __tablename__ = 'alpr_group' + + id = db.Column(db.Integer, primary_key=True) + data_type = db.Column(db.String, default="alpr_group") + version = db.Column(db.Integer) + epoch_start = db.Column(db.Integer) + epoch_end = db.Column(db.Integer) + frame_start = db.Column(db.Integer) + frame_end = db.Column(db.Integer) + company_id = db.Column(db.String) + agent_uid = db.Column(db.String) + agent_version = db.Column(db.String) + agent_type = db.Column(db.String) + camera_id = db.Column(db.Integer) + gps_latitude = db.Column(db.Integer) + gps_longitude = db.Column(db.Integer) + country = db.Column(db.String) + uuids = db.Column(MutableList.as_mutable(db.JSON)) + vehicle_path = db.Column(MutableList.as_mutable(db.JSON)) + plate_indexes = db.Column(MutableList.as_mutable(db.JSON)) + candidates = db.Column(MutableList.as_mutable(db.JSON)) + best_plate = db.Column(MutableDict.as_mutable(db.JSON)) + best_confidence = db.Column(db.Integer) + best_plate_number = db.Column(db.String) + best_region = db.Column(db.String) + best_region_confidence = db.Column(db.Integer) + matches_template = db.Column(db.String) # Boolean + plate_path = db.Column(MutableList.as_mutable(db.JSON)) + vehicle_crop_jpeg = db.Column(db.String) + overview_jpeg = db.Column(db.String) + best_uuid = db.Column(db.String) + best_uuid_epoch_ms = db.Column(db.Integer) + best_image_width = db.Column(db.Integer) + best_image_height = db.Column(db.Integer) + travel_direction = db.Column(db.Integer) + is_parked = db.Column(db.String) # Boolean + is_preview = db.Column(db.String) # Boolean + vehicle_signature = db.Column(db.String) + vehicle = db.Column(MutableDict.as_mutable(db.JSON)) + web_server_config = db.Column(MutableDict.as_mutable(db.JSON)) + direction_of_travel_id = db.Column(db.String) + custom_data = db.Column(MutableDict.as_mutable(db.JSON)) + + # Custom + best_confidence_percent = db.Column(db.String) + travel_direction_class_tag = db.Column(db.String) + uuid_jpg = db.Column(db.String) + + def __init__(self, **kwargs): + super(ALPRGroup, self).__init__(**kwargs) + self.start_of_month_timestamp = datetime.now().timestamp() + self.end_of_month_timestamp = datetime.now().timestamp() + self.collection = [] + + @classmethod + def filter_by_id(cls, _id: int) -> "ALPRGroup": + return cls.query.filter_by(id=_id).first() + + @classmethod + def filter_by_id_and_beautify(cls, _id: int) -> {}: + record = cls.filter_by_id(_id) + + if record: + dt = helpers.Timezone(current_user, msecs=True) + return { + 'best_plate_number': record.best_plate_number, + 'uuid_jpg': record.uuid_jpg, + 'overview_jpeg': record.overview_jpeg, + 'vehicle_crop_jpeg': record.vehicle_crop_jpeg, + 'plate_crop_jpeg': record.best_plate['plate_crop_jpeg'], + 'agent_label': record.web_server_config['agent_label'], + 'agent_uid': record.agent_uid, + 'agent_version': record.agent_version, + 'agent_type': record.agent_type, + 'camera_label': record.web_server_config['camera_label'], + 'camera_id': record.camera_id, + 'gps_latitude': record.gps_latitude, + 'gps_longitude': record.gps_longitude, + 'country': beautify.name(record.country), + 'id': record.id, + 'epoch_start': record.epoch_start, + 'epoch_start_datetime': dt.astimezone(record.epoch_start), + 'epoch_end': record.epoch_end, + 'epoch_end_datetime': dt.astimezone(record.epoch_start), + 'best_confidence_percent': record.best_confidence_percent, + 'best_region': beautify.country(record.best_region), + 'travel_direction_class_tag': beautify.direction(record.travel_direction), + 'travel_direction': round(float(record.travel_direction), 0), + 'vehicle_color_name': beautify.name(record.vehicle['color'][0]['name']), + 'vehicle_color_confidence': beautify.round_percentage(record.vehicle['color'][0]['confidence']), + 'vehicle_year_name': record.vehicle['year'][0]['name'], + 'vehicle_year_confidence': beautify.round_percentage(record.vehicle['year'][0]['confidence']), + 'vehicle_make_name': beautify.name(record.vehicle['make'][0]['name']), + 'vehicle_make_confidence': beautify.round_percentage(record.vehicle['make'][0]['confidence']), + 'vehicle_make_model_name': beautify.name(record.vehicle['make_model'][0]['name']), + 'vehicle_make_model_confidence': beautify.round_percentage(record.vehicle['make_model'][0]['confidence']), + 'vehicle_body_type_name': beautify.name(record.vehicle['body_type'][0]['name']), + 'vehicle_body_type_confidence': beautify.round_percentage(record.vehicle['body_type'][0]['confidence']) + } + + else: + return None + + @classmethod + def get_latest_agent_label(cls, _agent_uid: int) -> str: + record = cls.query.filter_by(agent_uid=_agent_uid).order_by(ALPRGroup.id.desc()).first() + return record.web_server_config['agent_label'] + + @classmethod + def get_latest_agent_type(cls, _agent_uid: int) -> str: + record = cls.query.filter_by(agent_uid=_agent_uid).order_by(ALPRGroup.id.desc()).first() + return record.agent_type + + @classmethod + def get_latest_agent_version(cls, _agent_uid: int) -> str: + record = cls.query.filter_by(agent_uid=_agent_uid).order_by(ALPRGroup.id.desc()).first() + return record.agent_version + + @classmethod + def get_latest_camera_label(cls, _camera_id: int) -> str: + record = cls.query.filter_by(camera_id=_camera_id).order_by(ALPRGroup.id.desc()).first() + return record.web_server_config['camera_label'] + + @classmethod + def get_latest_camera_gps_latitude(cls, _camera_id: int) -> str: + record = cls.query.filter_by(camera_id=_camera_id).order_by(ALPRGroup.id.desc()).first() + return record.gps_latitude + + @classmethod + def get_latest_camera_country(cls, _camera_id: int) -> str: + record = cls.query.filter_by(camera_id=_camera_id).order_by(ALPRGroup.id.desc()).first() + return record.country + + @classmethod + def get_latest_camera_gps_longitude(cls, _camera_id: int) -> str: + record = cls.query.filter_by(camera_id=_camera_id).order_by(ALPRGroup.id.desc()).first() + return record.gps_longitude + + @classmethod + def get_oldest_agent_epoch_start(cls, _agent_uid: int) -> int: + record = cls.query.filter_by(agent_uid=_agent_uid).order_by(ALPRGroup.id.asc()).first() + return record.epoch_start + + @classmethod + def get_oldest_camera_epoch_start(cls, _camera_id: int) -> int: + record = cls.query.filter_by(camera_id=_camera_id).order_by(ALPRGroup.id.asc()).first() + return record.epoch_start + + def filter_epoch_start(self) -> []: + self.collection = self.query.filter((ALPRGroup.epoch_start / 1000) >= self.start_of_month_timestamp).filter( + (ALPRGroup.epoch_start / 1000) <= self.end_of_month_timestamp).all() + return self.collection + + def get_all_agent_uids(self) -> {}: + if self.collection is None: + return {} + + agent_uids = {} + + # Add every unique agent_uid for that month onto a list + for record in self.collection: + if record.agent_uid not in agent_uids: + agent_uids[record.agent_uid] = record.web_server_config['agent_label'] + + return agent_uids + + def get_cameras_and_counts(self) -> {}: + if self.collection is None: + return {} + + cameras = [] + + # Add every camera for that month onto a list + for record in self.collection: + cameras.append(record.camera_id) + + # Go through each camera and get a count + dictionary = {} + for camera in cameras: + dictionary[camera] = cameras.count(camera) + + # Sort the dictionary from greatest to least + return dict(sorted(dictionary.items(), key=lambda count: count[1], reverse=True)) + + def get_all_regions(self) -> {}: + if self.collection is None: + return {} + + regions = [] + + # Add every region for that month onto a list + for record in self.collection: + regions.append(record.best_region) + + # Go through each region and get a count + dictionary = {} + for region in regions: + dictionary[region] = regions.count(region) + + # Sort the dictionary from greatest to least + return dict(sorted(dictionary.items(), key=lambda count: count[1], reverse=True)) + + def get_dashboard_records(self, n=8) -> []: + records = self.query.order_by(ALPRGroup.id.desc()).limit(n) + modified_records = [] + dt = helpers.Timezone(current_user) + + for record in records: + modified_records.append({ + 'agent_label': record.web_server_config['agent_label'], + 'camera_label': record.web_server_config['camera_label'], + 'id': record.id, + 'best_plate_number': record.best_plate_number, + 'travel_direction_class_tag': record.travel_direction_class_tag, + 'best_confidence_percent': record.best_confidence_percent, + 'epoch_start_datetime': dt.astimezone(record.epoch_start) + }) + + return modified_records + + def query_all(self) -> []: + return self.query.all() + + def save(self) -> None: + try: + db.session.add(self) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + logging.exception(e) + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) + + def to_dic(self): + return {c.name: getattr(self, c.name) for c in self.__table__.columns} diff --git a/apps/alpr/models/cache.py b/apps/alpr/models/cache.py new file mode 100644 index 0000000..86e2429 --- /dev/null +++ b/apps/alpr/models/cache.py @@ -0,0 +1,685 @@ +import logging +import os +import platform +from datetime import datetime, timedelta + +from markupsafe import Markup + +from apps import db +from sqlalchemy.exc import SQLAlchemyError + +from apps.alpr import beautify +from apps.alpr.enums import ChartType +from apps.alpr.models.alpr_group import ALPRGroup +from apps.alpr.models.alpr_alert import ALPRAlert +from apps.alpr.models.settings import AgentSettings, CameraSettings +from apps.alpr.models.vehicle import Vehicle +from apps.exceptions.exception import InvalidUsage +from sqlalchemy_json import NestedMutableJson + + +class Cache(db.Model): + __bind_key__ = 'cache' + __tablename__ = 'Cache' + + id = db.Column(db.Integer, primary_key=True) + year = db.Column(db.Integer) + all_time_plates_captured = db.Column(db.Integer, default=0) + all_time_alerts = db.Column(db.Integer, default=0) + all_time_vehicles = db.Column(db.Integer, default=0) + month = db.Column(NestedMutableJson, + default=[{"license_plates_captured": 0, "alerts": 0, "vehicles": 0, "cameras": {}, "regions": {}}, + {"license_plates_captured": 0, "alerts": 0, "vehicles": 0, "cameras": {}, "regions": {}}, + {"license_plates_captured": 0, "alerts": 0, "vehicles": 0, "cameras": {}, "regions": {}}, + {"license_plates_captured": 0, "alerts": 0, "vehicles": 0, "cameras": {}, "regions": {}}, + {"license_plates_captured": 0, "alerts": 0, "vehicles": 0, "cameras": {}, "regions": {}}, + {"license_plates_captured": 0, "alerts": 0, "vehicles": 0, "cameras": {}, "regions": {}}, + {"license_plates_captured": 0, "alerts": 0, "vehicles": 0, "cameras": {}, "regions": {}}, + {"license_plates_captured": 0, "alerts": 0, "vehicles": 0, "cameras": {}, "regions": {}}, + {"license_plates_captured": 0, "alerts": 0, "vehicles": 0, "cameras": {}, "regions": {}}, + {"license_plates_captured": 0, "alerts": 0, "vehicles": 0, "cameras": {}, "regions": {}}, + {"license_plates_captured": 0, "alerts": 0, "vehicles": 0, "cameras": {}, "regions": {}}, + {"license_plates_captured": 0, "alerts": 0, "vehicles": 0, "cameras": {}, "regions": {}}] + ) + + def __init__(self, year=datetime.now().year, month=datetime.now().month): + self.year = year + # Not to be confused with month = db.Column() + # Should be an integer from 1 - 12 + self._month_ = month + + @classmethod + def filter_by_year(cls, year=datetime.now().year) -> "Cache": + # CHeck to see if a row/year exists + cache = cls.query.filter_by(year=year).first() + + # Create a new row/year if it doesn't exist + if cache is None: + cache = Cache(year=year) + cache.save() + + return cache + + def get_alert_count(self, year: int, month: int) -> int: + cache = self.filter_by_year(year) + if cache is None: + return 0 + + return cache.month[month - 1]['alerts'] + + def get_all_db_file_sizes(self, raw=False) -> str: + if raw: + return str(os.path.getsize("apps/db/alpr_alert.sqlite") + \ + os.path.getsize("apps/db/alpr_group.sqlite") + \ + os.path.getsize("apps/db/vehicle.sqlite")) + + return beautify.human_size(os.path.getsize("apps/db/alpr_alert.sqlite") + + os.path.getsize("apps/db/alpr_group.sqlite") + + os.path.getsize("apps/db/vehicle.sqlite")) + + def get_all_time_alerts_count(self) -> int: + total_alerts = 0 + alerts = self.query.all() + + for alert in alerts: + total_alerts += alert.all_time_alerts + + return total_alerts + + def get_all_time_plates_count(self) -> int: + total_plates_captured = 0 + years = self.query.all() + + for year in years: + total_plates_captured += year.all_time_plates_captured + + return total_plates_captured + + def get_all_time_vehicle_count(self) -> int: + total_vehicles = 0 + vehicles = self.query.all() + + for vehicle in vehicles: + total_vehicles += vehicle.all_time_vehicles + + return total_vehicles + + def get_chart_series(self, chart: ChartType) -> []: + series = [] + + year = datetime.now().year + # Move to previous month + last_month = datetime.now().month - 1 + # Move to previous year when needed + if last_month == 0: + year -= 1 + last_month = 12 + + for i in range(12): + if chart == ChartType.PLATES_CAPTURED_CHART or chart == ChartType.TOP_REGION_CHART: + # License Plates Captured + this_month_license_plate_count = self.get_license_plate_count(year, last_month) + + if chart == ChartType.PLATES_CAPTURED_CHART: + series.append(this_month_license_plate_count) + elif chart == ChartType.TOP_REGION_CHART: + # Top Region + this_month_top_region_count = self.get_top_region_count(year, last_month) + series.append(this_month_top_region_count) + + elif chart == ChartType.ALERT_CHART: + # Alerts + this_month_alert_count = self.get_alert_count(year, last_month) + series.append(this_month_alert_count) + + # Move to previous month + last_month -= 1 + # Move to previous year when needed + if last_month == 0: + year -= 1 + last_month = 12 + + return Markup(list(reversed(series))) + + def get_chart_labels(self, chart=None) -> []: + series = [] + + year = datetime.now().year + # Move to previous month + month = datetime.now().month - 1 + # Move to previous year when needed + + for i in range(12): + start_of_month = datetime(year=year, month=month, day=1) + + if chart == ChartType.TOP_REGION_CHART: + pretty_region = beautify.country(self.get_top_region(year, month)) + series.append("{} - {}".format(start_of_month.strftime("%b %Y"), pretty_region)) + else: + series.append(start_of_month.strftime("%b %Y")) + + # Move to previous month + month -= 1 + # Move to previous year when needed + if month == 0: + year -= 1 + month = 12 + + return Markup(list(reversed(series))) + + # Thanks to augustomen @stackoverflow.com + # https://stackoverflow.com/a/13565185 + def get_last_day_of_month(self, any_day: datetime) -> datetime: + # The day 28 exists in every month. 4 days later, it's always next month + next_month = any_day.replace(day=28) + timedelta(days=4) + # subtracting the number of the current day brings us back one month + return next_month - timedelta(days=next_month.day) + + def get_license_plate_count(self, year: int, month: int) -> int: + cache = self.filter_by_year(year) + if cache is None: + return 0 + + return cache.month[month - 1]['license_plates_captured'] + + def get_number_of_records(self, raw=False) -> str: + if raw: + return str(self.get_all_time_plates_count() + + self.get_all_time_alerts_count() + + self.get_all_time_vehicle_count()) + + return beautify.human_format(self.get_all_time_plates_count() + + self.get_all_time_alerts_count() + + self.get_all_time_vehicle_count()) + + def get_quick_stats(self) -> {}: + response = {} + + # Get the number of plates and alerts for this month + start_of_month = datetime.today().replace(day=1) + end_of_month = self.get_last_day_of_month(datetime.now()) + + if platform.system() == "Windows": + response['start_of_month_pretty'] = start_of_month.strftime("%b %e") + response['end_of_month_pretty'] = end_of_month.strftime("%b %e") + else: + response['start_of_month_pretty'] = start_of_month.strftime("%b %-d") + response['end_of_month_pretty'] = end_of_month.strftime("%b %-d") + + year = datetime.now().year + month = datetime.now().month + # Move to previous month + last_month = datetime.now().month - 1 + # Move to previous year when needed + if last_month == 0: + year -= 1 + last_month = 12 + + this_month_alert_count = self.get_alert_count(year, month) + this_month_license_plate_count = self.get_license_plate_count(year, month) + # print("this_month_license_plate_count = {}".format(this_month_license_plate_count)) + this_month_top_region = self.get_top_region(year, month) + # print("this_month_top_region = {}".format(this_month_top_region)) + this_month_top_region_count = self.get_top_region_count(year, month) + # print("this_month_top_region_count = {}".format(this_month_top_region_count)) + + response['this_month_alert_count'] = this_month_alert_count + response['this_month_license_plate_count'] = this_month_license_plate_count + response['this_month_top_region'] = beautify.country(this_month_top_region) + response['this_month_top_region_count'] = this_month_top_region_count + + last_month_alert_count = self.get_alert_count(year, last_month) + last_month_license_plate_count = self.get_license_plate_count(year, last_month) + # print("last_month_license_plate_count = {}".format(last_month_license_plate_count)) + last_month_top_region = self.get_top_region(year, last_month) + # print("last_month_top_region = {}".format(last_month_top_region)) + last_month_top_region_count = self.get_region_count(year, last_month, this_month_top_region) + # print("last_month_top_region_count = {}".format(last_month_top_region_count)) + + response['last_month_alert_count'] = last_month_alert_count + response['last_month_license_plate_count'] = last_month_license_plate_count + response['last_month_top_region'] = beautify.country(last_month_top_region) + response['last_month_top_region_count'] = last_month_top_region_count + + # Calculate Month-To-Month for the number of plates captured + if last_month_license_plate_count != 0: + mtm_license_plates_percent = ((this_month_license_plate_count / last_month_license_plate_count) - 1) * 100 + mtm_license_plates_percent = round(mtm_license_plates_percent, 1) + # Determine the color/class of the percentage + if mtm_license_plates_percent < 0: + response['this_month_license_plates_mtm_class'] = "text-danger" + response['this_month_license_plates_mtm_svg_html_arrow'] = beautify.get_negative_mtm_svg_html_arrow() + elif mtm_license_plates_percent == 0: + response['this_month_license_plates_mtm_class'] = "" + elif mtm_license_plates_percent > 0: + response['this_month_license_plates_mtm_class'] = "text-success" + response['this_month_license_plates_mtm_svg_html_arrow'] = beautify.get_positive_mtm_svg_html_arrow() + elif last_month_license_plate_count == 0 and this_month_license_plate_count != 0: + mtm_license_plates_percent = 100 + response['this_month_license_plates_mtm_class'] = "text-success" + response['this_month_license_plates_mtm_svg_html_arrow'] = beautify.get_positive_mtm_svg_html_arrow() + else: + mtm_license_plates_percent = 0 + response['this_month_license_plates_mtm_class'] = "" + response['this_month_license_plates_mtm_svg_html_arrow'] = "" + + response["this_month_license_plates_mtm_percent"] = mtm_license_plates_percent + + # Calculate Month-To-Month for the number of alerts captured + if last_month_alert_count != 0: + mtm_alerts_percent = ((this_month_alert_count / last_month_alert_count) - 1) * 100 + mtm_alerts_percent = round(mtm_alerts_percent, 1) + # Determine the color/class of the percentage + if mtm_alerts_percent < 0: + response['this_month_alerts_mtm_class'] = "text-danger" + response['this_month_alerts_mtm_svg_html_arrow'] = beautify.get_negative_mtm_svg_html_arrow() + elif mtm_alerts_percent == 0: + response['this_month_alerts_mtm_class'] = "" + elif mtm_alerts_percent > 0: + response['this_month_alerts_mtm_class'] = "text-success" + response['this_month_alerts_mtm_svg_html_arrow'] = beautify.get_positive_mtm_svg_html_arrow() + elif last_month_alert_count == 0 and this_month_alert_count != 0: + mtm_alerts_percent = 100 + response['this_month_alerts_mtm_class'] = "text-success" + response['this_month_alerts_mtm_svg_html_arrow'] = beautify.get_positive_mtm_svg_html_arrow() + else: + mtm_alerts_percent = 0 + response['this_month_alerts_mtm_class'] = "" + response['this_month_alerts_mtm_svg_html_arrow'] = "" + + response["this_month_alerts_mtm_percent"] = mtm_alerts_percent + + # Calculate Month-To-Month for the top region + if last_month_top_region != "N/A" and this_month_top_region != "N/A": + mtm_region_percent = ((this_month_top_region_count / last_month_top_region_count) - 1) * 100 + mtm_region_percent = round(mtm_region_percent, 1) + # Determine the color/class of the percentage + if mtm_region_percent < 0: + response['this_month_top_region_mtm_class'] = "text-danger" + response['this_month_top_region_mtm_svg_html_arrow'] = beautify.get_negative_mtm_svg_html_arrow() + elif mtm_region_percent == 0: + response['this_month_top_region_mtm_class'] = "" + elif mtm_region_percent > 0: + response['this_month_top_region_mtm_class'] = "text-success" + response['this_month_top_region_mtm_svg_html_arrow'] = beautify.get_positive_mtm_svg_html_arrow() + elif last_month_top_region_count == 0 and this_month_top_region_count != 0: + mtm_region_percent = 100 + response['this_month_top_region_mtm_class'] = "text-success" + response['this_month_top_region_mtm_svg_html_arrow'] = beautify.get_positive_mtm_svg_html_arrow() + else: + mtm_region_percent = 0 + response['this_month_top_region_mtm_class'] = "" + response['this_month_top_region_mtm_svg_html_arrow'] = "" + + response["this_month_top_region_mtm_percent"] = mtm_region_percent + + return response + + def get_regions(self, year: int, month: int) -> {}: + cache = self.filter_by_year(year) + if cache is None: + return {} + + return cache.month[month - 1]['regions'] + + def get_top_cameras(self, n=3) -> {}: + cache = self.filter_by_year() + month = datetime.now().month + dic = cache.month[month - 1]['cameras'] + + cameras = [] + i = 0 + # Resolve camera ids to labels + for key, value in dic.items(): + # Limit the amount of cameras to n + if i >= n: + break + # camera_id, label, count, latitude, longitude + cameras.append((key, CameraCache.get_camera_label(key), value, CameraCache.get_camera_gps_latitude(key), + CameraCache.get_camera_gps_longitude(key))) + i += 1 + + return cameras + + def get_us_map_regions(self, n=8) -> []: + regions = [] + + year = datetime.now().year + # Move to previous month + month = datetime.now().month - 1 + # Move to previous year when needed + if month == 0: + year -= 1 + month = 12 + + last_month_license_plate_count = self.get_license_plate_count(year, month) + last_month_regions = self.get_regions(year, month) + + for region in last_month_regions: + regions.append({"name": beautify.country(region), "count": last_month_regions[region], + "percent": (beautify.round_percentage( + (last_month_regions[region] / last_month_license_plate_count) * 100, symbol=False)), + "total": last_month_license_plate_count, "flag_uri": beautify.get_flag_uri(region)}) + + if n != 0: + return regions[:n] + else: + return regions + + def get_us_map_series(self) -> []: + year = datetime.now().year + # Move to previous month + month = datetime.now().month - 1 + # Move to previous year when needed + if month == 0: + year -= 1 + month = 12 + + regions = self.get_regions(year, month) + us_regions = [] + + if len(regions) == 0: + return Markup([]) + + for key in regions: + if str(key).split("-")[0] == "us": + us_regions.append([str(key).split("-")[1].upper(), regions[key]]) + + return Markup(us_regions) + + def get_region_count(self, year: int, month: int, region: str) -> int: + if region == "N/A": + return 0 + + cache = self.filter_by_year(year) + if cache is None: + return 0 + + return int(cache.month[month - 1]['regions'].get(region)) + + def get_top_region(self, year, month) -> str: + cache = self.filter_by_year(year) + if cache is None: + return "N/A" + + length = len(list(cache.month[month - 1]['regions'])) + return str(list(cache.month[month - 1]['regions'].keys())[1]) if length != 0 else "N/A" + + def get_top_region_count(self, year: int, month: int) -> int: + cache = self.filter_by_year(year) + if cache is None: + return 0 + + length = len(list(cache.month[month - 1]['regions'])) + return int(list(cache.month[month - 1]['regions'].values())[1]) if length != 0 else 0 + + def init(self): + try: + # Get the number of plates from the previous 11 months + # Monday - Sunday + # now: 2023-01-16 10:02:43.651522 + # start_of_week: 2023-01-16 00:00:00 + # end_of_week: 2023-01-22 23:59:59 + now = datetime.now() + month = now.month + year = now.year + + alpr_group = ALPRGroup() + alpr_alert = ALPRAlert() + vehicle = Vehicle() + + for i in range(12): + # Get the row corresponding to this year or create it + cache = self.filter_by_year(year) + + start_of_month = datetime(year=year, month=month, day=1, hour=0, minute=0, second=0) + end_of_month = self.get_last_day_of_month(datetime(year=year, month=month, day=1, hour=23, minute=59, + second=59)) + start_of_month_timestamp = start_of_month.timestamp() + end_of_month_timestamp = end_of_month.timestamp() + + alpr_group.start_of_month_timestamp = start_of_month_timestamp + alpr_group.end_of_month_timestamp = end_of_month_timestamp + alpr_alert.start_of_month_timestamp = start_of_month_timestamp + alpr_alert.end_of_month_timestamp = end_of_month_timestamp + vehicle.start_of_month_timestamp = start_of_month_timestamp + vehicle.end_of_month_timestamp = end_of_month_timestamp + + # License Plates + this_month_license_plates = alpr_group.filter_epoch_start() + this_month_license_plate_count = 0 if this_month_license_plates is None else len( + this_month_license_plates) + + # Alerts + this_month_alerts = alpr_alert.filter_epoch_time() + this_month_alert_count = 0 if this_month_alerts is None else len(this_month_alerts) + + # Vehicles + this_month_vehicles = vehicle.filter_epoch_start() + this_month_vehicle_count = 0 if this_month_vehicles is None else len(this_month_vehicles) + + # Regions + this_month_regions = alpr_group.get_all_regions() + + # Cameras and counts + this_month_cameras_and_counts = alpr_group.get_cameras_and_counts() + + # Agent UIDs for settings + this_month_agent_uids = alpr_group.get_all_agent_uids() + + # Add camera_ids into cache and settings along with the most updated labels + for camera_id, count in this_month_cameras_and_counts.items(): + camera_cache = CameraCache.filter_by_camera_id(camera_id) + camera_settings = CameraSettings.filter_by_camera_id(camera_id) + camera_label = ALPRGroup.get_latest_camera_label(camera_id) + camera_gps_latitude = ALPRGroup.get_latest_camera_gps_latitude(camera_id) + camera_gps_longitude = ALPRGroup.get_latest_camera_gps_longitude(camera_id) + camera_country = ALPRGroup.get_latest_camera_country(camera_id) + + # Cache + if camera_cache is None: + camera_cache = CameraCache(camera_id, camera_label) + else: + camera_cache.camera_label = camera_label + + # Update user definable values regardless if the camera was found in the cache or not + camera_cache.gps_latitude = camera_gps_latitude + camera_cache.gps_longitude = camera_gps_longitude + camera_cache.country = camera_country + # Save it + camera_cache.save() + + # Settings + if camera_settings is None: + camera_settings = CameraSettings(camera_id, camera_label) + camera_settings.created = datetime.fromtimestamp( + ALPRGroup.get_oldest_camera_epoch_start(camera_id) / 1000) + else: + camera_settings.camera_label = camera_label + camera_settings.save() + + # Add agent_uids into settings and cache + for agent_uid, agent_label in this_month_agent_uids.items(): + agent_settings = AgentSettings.filter_by_agent_uid(agent_uid) + agent_cache = AgentCache.filter_by_agent_uid(agent_uid) + if agent_settings is None: + agent_settings = AgentSettings(agent_uid, alpr_group.get_latest_agent_label(agent_uid)) + agent_settings.created = datetime.fromtimestamp(ALPRGroup.get_oldest_agent_epoch_start(agent_uid) / 1000) + agent_settings.save() + if agent_cache is None: + agent_cache = AgentCache(agent_uid, alpr_group.get_latest_agent_label(agent_uid)) + + # These can be updated, so it's best to always overwrite them on each occurrence + agent_cache.agent_version = alpr_group.get_latest_agent_version(agent_uid) + agent_cache.agent_type = alpr_group.get_latest_agent_type(agent_uid) + agent_cache.save() + + + # Populate cache + cache.all_time_plates_captured += this_month_license_plate_count + cache.all_time_alerts += this_month_alert_count + cache.all_time_vehicles += this_month_vehicle_count + cache.month[month - 1]['license_plates_captured'] = this_month_license_plate_count + cache.month[month - 1]['alerts'] = this_month_alert_count + cache.month[month - 1]['vehicles'] = this_month_vehicle_count + cache.month[month - 1]['cameras'] = this_month_cameras_and_counts + cache.month[month - 1]['regions'] = this_month_regions + + # Save it in the db + self.save() + + # Move to previous month + month = month - 1 + # Move to previous year when needed + if month == 0: + year = year - 1 + month = 12 + + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) + + def save(self) -> None: + try: + db.session.add(self) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) + + def delete(self) -> None: + try: + db.session.delete(self) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) + return + + +class AgentCache(db.Model): + __bind_key__ = 'cache' + __tablename__ = 'AgentCache' + + id = db.Column(db.Integer, primary_key=True) + agent_uid = db.Column(db.Integer) + agent_label = db.Column(db.String) + agent_version = db.Column(db.Integer) + agent_type = db.Column(db.Integer) + + def __init__(self, agent_uid: int, agent_label: str): + self.agent_uid = agent_uid + self.agent_label = agent_label + + @classmethod + def filter_by_agent_uid(cls, _agent_uid: str) -> "AgentCache": + # Check to see if a row/agent_id exists + return cls.query.filter_by(agent_uid=_agent_uid).first() + + @classmethod + def get_agent_label(cls, _agent_uid: str) -> "AgentCache": + agent = cls.query.filter_by(agent_uid=_agent_uid).first() + return agent.agent_label + + def save(self) -> None: + try: + db.session.add(self) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) + + +class CameraCache(db.Model): + __bind_key__ = 'cache' + __tablename__ = 'CameraCache' + + id = db.Column(db.Integer, primary_key=True) + camera_id = db.Column(db.Integer) + camera_label = db.Column(db.String) + gps_latitude = db.Column(db.Integer) + gps_longitude = db.Column(db.Integer) + country = db.Column(db.String) + + def __init__(self, camera_id: int, camera_label: str): + self.camera_id = camera_id + self.camera_label = camera_label + + @classmethod + def filter_by_camera_id(cls, _camera_id: int) -> "CameraCache": + # Check to see if a row/camera_id exists + return cls.query.filter_by(camera_id=_camera_id).first() + + @classmethod + def filter_by_id_and_beautify(cls, _camera_id: int) -> "CameraCache": + camera = cls.query.filter_by(camera_id=_camera_id).first() + + if camera: + camera.country = beautify.name(camera.country) + + return camera + + @classmethod + def get_camera_label(cls, _camera_id: int) -> "CameraCache": + camera = cls.query.filter_by(camera_id=_camera_id).first() + return camera.camera_label + + @classmethod + def get_camera_gps_latitude(cls, _camera_id: int) -> "CameraCache": + camera = cls.query.filter_by(camera_id=_camera_id).first() + return camera.gps_latitude + + @classmethod + def get_camera_gps_longitude(cls, _camera_id: int) -> "CameraCache": + camera = cls.query.filter_by(camera_id=_camera_id).first() + return camera.gps_longitude + + def save(self) -> None: + try: + db.session.add(self) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) + + +class Counter(db.Model): + __bind_key__ = 'cache' + __tablename__ = 'Counter' + + id = db.Column(db.Integer, primary_key=True) + key = db.Column(db.String) + count = db.Column(db.Integer) + + def __init__(self, key: str): + self.key = key + self.count = 0 + + @classmethod + def filter_by_key(cls, key: str) -> "Counter": + return cls.query.filter_by(key=key).first() + + def one_down(self): + self.count -= 1 + + def one_up(self): + self.count += 1 + + def save(self) -> None: + try: + db.session.add(self) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + logging.exception(e) diff --git a/apps/alpr/models/custom_alert.py b/apps/alpr/models/custom_alert.py new file mode 100644 index 0000000..2d75611 --- /dev/null +++ b/apps/alpr/models/custom_alert.py @@ -0,0 +1,125 @@ +from datetime import datetime + +from flask_login import current_user + +from apps import db, helpers +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.mutable import MutableList, MutableDict + +from apps.alpr import beautify +from apps.alpr.models.alpr_group import ALPRGroup +from apps.exceptions.exception import InvalidUsage +import apps.helpers as helper + + +class CustomAlert(db.Model): + __bind_key__ = 'custom_alert' + __tablename__ = 'custom_alert' + + id = db.Column(db.Integer, primary_key=True) + alpr_group_id = db.Column(db.Integer) + license_plate = db.Column(db.String) + region_match = db.Column(db.Boolean) + description = db.Column(db.String) + notify_user_ids = db.Column(MutableList.as_mutable(db.JSON)) + submitted_by_user_id = db.Column(db.Integer) + + def __init__(self, **kwargs): + super(CustomAlert, self).__init__(**kwargs) + + @classmethod + def filter_by_id(cls, _id: int) -> "CustomAlert": + return cls.query.filter_by(id=_id).first() + + @classmethod + def filter_by_id_and_beautify(cls, _id: int) -> {}: + custom_alert = cls.filter_by_id(_id) + alpr_group = ALPRGroup() + + if custom_alert is not None: + alpr_group = ALPRGroup.filter_by_id(custom_alert.alpr_group_id) + + if alpr_group is None: + return None + + if custom_alert: + dt = helpers.Timezone(current_user, msecs=True) + return { + 'api_key': str(alpr_group.custom_data['API_KEY'][-4:]).upper(), + 'description': custom_alert.description, + 'region_match': custom_alert.region_match, + 'best_plate_number': custom_alert.license_plate, + 'uuid_jpg': alpr_group.uuid_jpg, + 'overview_jpeg': alpr_group.overview_jpeg, + 'vehicle_crop_jpeg': alpr_group.vehicle_crop_jpeg, + 'plate_crop_jpeg': alpr_group.best_plate['plate_crop_jpeg'], + 'agent_label': alpr_group.web_server_config['agent_label'], + 'agent_uid': alpr_group.agent_uid, + 'agent_version': alpr_group.agent_version, + 'agent_type': alpr_group.agent_type, + 'camera_label': alpr_group.web_server_config['camera_label'], + 'camera_id': alpr_group.camera_id, + 'gps_latitude': alpr_group.gps_latitude, + 'gps_longitude': alpr_group.gps_longitude, + 'country': beautify.name(alpr_group.country), + 'id': custom_alert.id, + 'alpr_group_id': custom_alert.alpr_group_id, + 'epoch_start': alpr_group.epoch_start, + 'epoch_start_datetime': dt.astimezone(alpr_group.epoch_start), + 'epoch_end': alpr_group.epoch_end, + 'epoch_end_datetime': dt.astimezone(alpr_group.epoch_start), + 'best_confidence_percent': alpr_group.best_confidence_percent, + 'best_region': beautify.country(alpr_group.best_region), + 'travel_direction_class_tag': beautify.direction(alpr_group.travel_direction), + 'travel_direction': round(float(alpr_group.travel_direction), 0), + 'vehicle_color_name': beautify.name(alpr_group.vehicle['color'][0]['name']), + 'vehicle_color_confidence': beautify.round_percentage(alpr_group.vehicle['color'][0]['confidence']), + 'vehicle_year_name': alpr_group.vehicle['year'][0]['name'], + 'vehicle_year_confidence': beautify.round_percentage(alpr_group.vehicle['year'][0]['confidence']), + 'vehicle_make_name': beautify.name(alpr_group.vehicle['make'][0]['name']), + 'vehicle_make_confidence': beautify.round_percentage(alpr_group.vehicle['make'][0]['confidence']), + 'vehicle_make_model_name': beautify.name(alpr_group.vehicle['make_model'][0]['name']), + 'vehicle_make_model_confidence': beautify.round_percentage(alpr_group.vehicle['make_model'][0]['confidence']), + 'vehicle_body_type_name': beautify.name(alpr_group.vehicle['body_type'][0]['name']), + 'vehicle_body_type_confidence': beautify.round_percentage(alpr_group.vehicle['body_type'][0]['confidence']) + } + + else: + return None + + @classmethod + def filter_by_license_plate(cls, _license_plate: str) -> "CustomAlert": + return cls.query.filter_by(license_plate=_license_plate).first() + + @classmethod + def filter_by_submitted_user_id(cls, _submitted_by_user_id: int) -> ["CustomAlert"]: + return cls.query.filter_by(submitted_by_user_id=_submitted_by_user_id).all() + + def get_dashboard_records(self, current_user, n=3) -> []: + custom_alerts = self.query.filter_by(submitted_by_user_id=current_user.id).order_by(CustomAlert.id.desc()).limit(n) + data = [] + dt = helper.Timezone(current_user) + + for record in custom_alerts: + alpr_group = ALPRGroup.filter_by_id(record.alpr_group_id) + data.append({ + 'id': record.id, + 'month': dt.month(alpr_group.epoch_start), + 'day': dt.day(alpr_group.epoch_start), + 'plate_number': record.license_plate, + 'epoch_time_datetime': dt.astimezone(alpr_group.epoch_start), + 'site_name': alpr_group.web_server_config['agent_label'], + 'camera_name': alpr_group.web_server_config['camera_label'], + 'description': helper.shorten_description(record.description) + }) + return data + + def save(self) -> None: + try: + db.session.add(self) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) diff --git a/apps/alpr/models/settings.py b/apps/alpr/models/settings.py new file mode 100644 index 0000000..784ce34 --- /dev/null +++ b/apps/alpr/models/settings.py @@ -0,0 +1,222 @@ +import base64 +import enum +import os +from datetime import datetime +from pathlib import Path + +from redis import Redis +from rq import Queue, Worker + +from apps import db + +from sqlalchemy.exc import SQLAlchemyError +from apps.exceptions.exception import InvalidUsage + + +with open(Path(os.path.abspath(os.path.dirname(__file__ + "../../../../") + + "/static/assets/img/brand/fishing-hook-bl.svg")).absolute(), "rb") as svg_file: + default_org_logo = base64.b64encode(svg_file.read()).decode("utf-8") + + +class PostAuth(enum.Enum): + DISABLE_POSTING = 0 + NO_AUTH = 1 + USERS_ADMINS = 2 + ADMINS_ONLY = 3 + + +class AgentSettings(db.Model): + __bind_key__ = 'settings' + __tablename__ = 'AgentSettings' + + id = db.Column(db.Integer, primary_key=True) + enabled = db.Column(db.Boolean, default=False) + agent_uid = db.Column(db.String) + agent_label = db.Column(db.String) + ip_hostname = db.Column(db.String) + port = db.Column(db.Integer, default=8355) + created = db.Column(db.DateTime, default=datetime.utcnow()) + last_seen = db.Column(db.DateTime, default=datetime.utcnow(), onupdate=datetime.utcnow()) + + def __init__(self, agent_uid: int, agent_label: str): + self.agent_uid = agent_uid + self.agent_label = agent_label + + @classmethod + def filter_by_agent_uid(cls, agent_uid: str) -> "AgentSettings": + return cls.query.filter_by(agent_uid=agent_uid).first() + + @classmethod + def filter_by_id(cls, id: str) -> "AgentSettings": + return cls.query.filter_by(id=id).first() + + @classmethod + def get_all(cls, _agent_uid: str) -> "AgentSettings": + return cls.query.all() + + def save(self) -> None: + try: + db.session.add(self) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) + + +class CameraSettings(db.Model): + __bind_key__ = 'settings' + __tablename__ = 'CameraSettings' + + id = db.Column(db.Integer, primary_key=True) + camera_id = db.Column(db.Integer) + camera_label = db.Column(db.String) + hostname = db.Column(db.String) + port = db.Column(db.Integer) + username = db.Column(db.String) + password = db.Column(db.String) + focus = db.Column(db.String) + zoom = db.Column(db.String) + focus_zoom_interval_check = db.Column(db.Integer) + notify_on_failed_interval_check = db.Column(db.Boolean) + manufacturer = db.Column(db.String) + enable = db.Column(db.Boolean, default=False) + created = db.Column(db.DateTime, default=datetime.utcnow()) + last_seen = db.Column(db.DateTime, default=datetime.utcnow(), onupdate=datetime.utcnow()) + + def __init__(self, camera_id: int, camera_label: str): + self.camera_id = camera_id + self.camera_label = camera_label + + @classmethod + def filter_by_camera_id(cls, _camera_id: int) -> "CameraSettings": + return cls.query.filter_by(camera_id=_camera_id).first() + + @classmethod + def filter_by_id(cls, _id: int) -> "CameraSettings": + return cls.query.filter_by(id=_id).first() + + @classmethod + def get_all_enabled(cls) -> []: + return cls.query.filter_by(enable=True) + + @classmethod + def get_camera_label(cls, _camera_id: int) -> "CameraSettings": + camera = cls.query.filter_by(camera_id=_camera_id).first() + return camera.camera_label + + def save(self) -> None: + try: + db.session.add(self) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) + + @classmethod + def start_wqs(cls): + all_enabled_cameras = cls.query.filter_by(enabled=True) + + for camera in all_enabled_cameras: + q = Queue(connection=Redis(), name=camera.id) + Worker(name=camera.id, queues=q) + + +class EmailNotificationSettings(db.Model): + __bind_key__ = 'settings' + __tablename__ = 'EmailNotificationSettings' + + id = db.Column(db.Integer, primary_key=True) + enabled = db.Column(db.Boolean, default=False) + hostname = db.Column(db.String) + port = db.Column(db.Integer) + username_email = db.Column(db.String) + password = db.Column(db.String) + recipients = db.Column(db.String) + + @classmethod + def get_recipients(cls) -> []: + settings = cls.query.filter_by(id=id).first() + recipients = settings.recipients + + return recipients.split(',') + + @classmethod + def get_settings(cls) -> "EmailNotificationSettings": + return cls.query.filter_by(id=1).first() + + @classmethod + def is_enabled(cls) -> bool: + settings = cls.query.filter_by(id=id).first() + return settings.enabled + + def save(self) -> None: + try: + db.session.add(self) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) + + +class GeneralSettings(db.Model): + __bind_key__ = 'settings' + __tablename__ = 'GeneralSettings' + + id = db.Column(db.Integer, primary_key=True) + logo = db.Column(db.String, default=default_org_logo) + org_name = db.Column(db.String, default="OpenALPR-Webhook") + post_auth = db.Column(db.Enum(PostAuth), default=PostAuth.ADMINS_ONLY) + public_url = db.Column(db.String) + + @classmethod + def get_settings(cls) -> "GeneralSettings": + return cls.query.filter_by(id=1).first() + + def save(self) -> None: + try: + db.session.add(self) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) + + +class TwilioNotificationSettings(db.Model): + __bind_key__ = 'settings' + __tablename__ = 'TwilioNotificationSettings' + + id = db.Column(db.Integer, primary_key=True) + enabled = db.Column(db.Boolean, default=False) + account_sid = db.Column(db.String) + auth_token = db.Column(db.String) + phone_number = db.Column(db.String) + recipients = db.Column(db.String) + + @classmethod + def get_recipients(cls) -> []: + settings = cls.query.filter_by(id=1).first() + recipients = settings.recipients + + return recipients.split(',') + + @classmethod + def get_settings(cls) -> "TwilioNotificationSettings": + return cls.query.filter_by(id=1).first() + + def save(self) -> None: + try: + db.session.add(self) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) diff --git a/apps/alpr/models/vehicle.py b/apps/alpr/models/vehicle.py new file mode 100644 index 0000000..8eee21d --- /dev/null +++ b/apps/alpr/models/vehicle.py @@ -0,0 +1,103 @@ +from datetime import datetime + +from flask_login import current_user + +from apps import db, helpers +from sqlalchemy.ext.mutable import MutableDict +from apps.exceptions.exception import InvalidUsage + + +class Vehicle(db.Model): + __bind_key__ = 'vehicle' + __tablename__ = 'vehicle' + + id = db.Column(db.Integer, primary_key=True) + data_type = db.Column(db.String, default="vehicle") + version = db.Column(db.Integer) + epoch_start = db.Column(db.Integer) + epoch_end = db.Column(db.Integer) + frame_start = db.Column(db.Integer) + frame_end = db.Column(db.Integer) + company_id = db.Column(db.String) + agent_uid = db.Column(db.String) + agent_version = db.Column(db.String) + agent_type = db.Column(db.String) + camera_id = db.Column(db.Integer) + gps_latitude = db.Column(db.Integer) + gps_longitude = db.Column(db.Integer) + country = db.Column(db.String) + vehicle_crop_jpeg = db.Column(db.String) + overview_jpeg = db.Column(db.String) + best_uuid = db.Column(db.String) + best_uuid_epoch_ms = db.Column(db.Integer) + best_image_width = db.Column(db.Integer) + best_image_height = db.Column(db.Integer) + travel_direction = db.Column(db.Integer) + is_parked = db.Column(db.Boolean) + is_preview = db.Column(db.Boolean) + vehicle_signature = db.Column(db.String) + vehicle = db.Column(MutableDict.as_mutable(db.JSON)) + custom_data = db.Column(MutableDict.as_mutable(db.JSON)) + + # Custom + travel_direction_class_tag = db.Column(db.String) + vehicle_color_name = db.Column(db.String) + vehicle_color_confidence = db.Column(db.String) + vehicle_make_name = db.Column(db.String) + vehicle_make_confidence = db.Column(db.String) + vehicle_make_model_name = db.Column(db.String) + vehicle_make_model_confidence = db.Column(db.String) + vehicle_body_type_name = db.Column(db.String) + vehicle_body_type_confidence = db.Column(db.String) + vehicle_year_name = db.Column(db.String) + vehicle_year_confidence = db.Column(db.String) + vehicle_missing_plate_name = db.Column(db.String) + vehicle_is_vehicle_name = db.Column(db.String) + vehicle_is_vehicle_confidence = db.Column(db.String) + uuid_jpg = db.Column(db.String) + + start_of_month_timestamp = datetime.now().timestamp() + end_of_month_timestamp = datetime.now().timestamp() + + def __init__(self, **kwargs): + super(Vehicle, self).__init__(**kwargs) + self.start_of_month_timestamp = datetime.now().timestamp() + self.end_of_month_timestamp = datetime.now().timestamp() + self.collection = [] + + @classmethod + def filter_by_id(cls, _id: int) -> "Vehicle": + return cls.query.filter_by(id=_id).first() + + @classmethod + def filter_epoch_start(self) -> "Vehicle": + self.collection = self.query.filter((Vehicle.epoch_start / 1000) >= self.start_of_month_timestamp).filter( + (Vehicle.epoch_start / 1000) <= self.end_of_month_timestamp).all() + return self.collection + + def get_records(self, n=8) -> []: + records = self.query.order_by(Vehicle.id.desc()).limit(n) + modified_records = [] + dt = helpers.Timezone(current_user) + + for record in records: + record.epoch_start = dt.astimezone(record.epoch_start) + record.epoch_end = dt.astimezone(record.epoch_end) + modified_records.append(record) + + return modified_records + + @classmethod + def query_all(cls) -> "Vehicle": + return cls.query.all() + + def save(self) -> None: + try: + db.session.add(self) + db.session.commit() + except Exception as e: + db.session.rollback() + db.session.close() + print(e) + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) diff --git a/apps/alpr/notify.py b/apps/alpr/notify.py new file mode 100644 index 0000000..a1e0e4b --- /dev/null +++ b/apps/alpr/notify.py @@ -0,0 +1,90 @@ +import enum +import smtplib +import ssl +from email.mime.text import MIMEText + +from twilio.rest import Client + +from apps.alpr.models.settings import TwilioNotificationSettings, EmailNotificationSettings + + +class Tag(enum.Enum): + ACCOUNT = 'Account' + ADMIN = 'Admin' + AGENT = 'Agent' + ALERT = 'Alert' + CAMERA = 'Camera' + SECURITY = 'Security' + TEST = 'Test' + + +class Email: + _settings = None + tag = "" + subject = "" + body = "" + recipients = [] + + def __init__(self): + self._settings = EmailNotificationSettings.get_settings() + self.recipients = self._settings.recipients + + def send(self) -> None: + + # Stop if Email is disabled + if not self._settings.enabled: + return + + try: + # Split the string to create a list: user1@example.com,user2@example.com -> + # -> ['user1@example.com', 'user2@example.com'] + recipients = self._settings.recipients.split(',') + + context = ssl.create_default_context() + with smtplib.SMTP_SSL(self._settings.hostname, self._settings.port, context=context) as server: + server.login(self._settings.username_email, self._settings.password) + for recipient in recipients: + msg = MIMEText(self.body) + msg['To'] = recipient + msg['From'] = self._settings.username_email + msg['Subject'] = "[{}] OpenALPR-Webhook: {}".format(self.tag, self.subject) + server.login(self._settings.username_email, self._settings.password) + server.send_message(msg) + except Exception as ex: + raise ex + + def send_test(self) -> None: + try: + self.tag = Tag.TEST + self.subject = "SMTP Test" + self.body = "This is a test 🧪 message from OpenALPR-Web🪝!" + self.send() + except Exception as ex: + raise ex + + +class SMS: + _settings = None + msg = "" + recipients = [] + + def __init__(self): + self._settings = TwilioNotificationSettings.get_settings() + self.recipients = self._settings.recipients + + def send(self) -> None: + try: + # Split the string to create a list: +12345678901,+12345678901 -> + # -> ['+12345678901', '+12345678901'] + recipients = self._settings.recipients.split(',') + + client = Client(self._settings.account_sid, self._settings.auth_token) + + for recipient in recipients: + client.messages.create(to=recipient, from_=self._settings.phone_number, body=self.msg) + except Exception as ex: + raise ex + + def send_test(self) -> None: + self.msg = "OpenALPR-Web🪝 Test 🧪".encode('utf-8') + self.send() diff --git a/apps/alpr/queue.py b/apps/alpr/queue.py new file mode 100644 index 0000000..a045f18 --- /dev/null +++ b/apps/alpr/queue.py @@ -0,0 +1,232 @@ +import base64 +import logging +import os +import shutil +import time +from pathlib import Path + +import requests + +from apps.alpr.models.alpr_alert import ALPRAlert +from apps.alpr.models.alpr_group import ALPRGroup +from apps.alpr.enums import DataType +from apps.alpr.models.custom_alert import CustomAlert +from apps.alpr.models.settings import AgentSettings, EmailNotificationSettings, CameraSettings, GeneralSettings +from apps.alpr.models.vehicle import Vehicle +from apps.alpr.notify import Email, SMS, Tag +from apps.alpr.routes.settings.cameras.manufacturers.Dahua import Dahua +from apps.authentication.models import User, UserProfile +from apps.authentication.routes import download_folder_name +import apps.helpers as helper + +from apps import create_app +from apps.config import config_dict + +# WARNING: Don't run with debug turned on in production! +DEBUG = os.getenv('DEBUG', 'False') == 'True' + +# The configuration +get_config_mode = 'Debug' if DEBUG else 'Production' + +# Load the configuration using the default values +app_config = config_dict[get_config_mode.capitalize()] + + +def download_plate_image(agent_uid: str, img_uuid: str, data_type: str, foreign_id: int): + # Make the application context available here. This function is forked into a separate process and the database + # connections needs to be reintroduced. + app = create_app(app_config) + app.app_context().push() + + # Get agent IP/hostname & port + agent = AgentSettings.filter_by_agent_uid(agent_uid) + email = Email() + email.tag = "Agent" + + if agent is None: + logging.info("Agent does not exist.") + email.subject = "Unknown Agent" + email.body = "Failed to download high resolution image. Agent does not exist." \ + "\n\nAgent UID: {}\nIMG UID: {}".format(agent_uid, img_uuid) + email.send() + raise Exception("Failed to download a plate image. Agent does not exist.") + elif agent.enabled: + try: + url = "http://{}:{}/img/{}.jpg".format(agent.ip_hostname, agent.port, img_uuid) + logging.info("Downloading: {}".format(url)) + # Download it + req = requests.get(url, stream=True) + + if req.status_code == 200: + # Create a path to save it to + full_file_path = Path(os.path.abspath(os.path.dirname(__file__) + "../../../") + "/" + + download_folder_name + img_uuid + ".jpg").absolute() + # Write to disk + with open(full_file_path, 'wb') as jpg: + shutil.copyfileobj(req.raw, jpg) + logging.info("File downloaded, location: {}".format(full_file_path)) + else: + logging.info("Failed to download high resolution plate image. HTTP status_code={}".format( + req.status_code)) + email.subject = "High Resolution Image Download Failed" + email.body = "Failed to download high resolution image. HTTP status code from agent was not 200.\n\n" \ + "Agent UID: {}\nIMG UID: {}\nHTTP Status: {}".format(agent_uid, img_uuid, + req.status_code) + email.send() + raise Exception("Failed to download the plate image. HTTP status_code={}".format(req.status_code)) + + except Exception as ex: + logging.info("Failed to download the plate image") + email.subject = "High Resolution Image Download Failed" + email.body = "Failed to download high resolution image. Incorrect IP/hostname & port? Make sure" \ + "OpenALPR-Webhook can access the agent.\n\nAgent UID: {}\nIMG UID: {}\n" \ + "Exception: {}".format(agent_uid, img_uuid, ex) + email.send() + raise Exception(ex) + + # Find the original record + record = None + + if data_type == DataType.GROUP: + record = ALPRGroup.filter_by_id(foreign_id) + elif data_type == DataType.ALERT: + record = ALPRAlert.filter_by_id(foreign_id) + elif data_type == DataType.VEHICLE: + record = Vehicle.filter_by_id(foreign_id) + + # Insert the image in the db + try: + # Read it while encoding it into base64 + with open(full_file_path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()) + uuid_jpg = encoded_string.decode("utf-8") + + # Insert into `uuid_jpg column` + record.uuid_jpg = uuid_jpg + + # Save/update the db + record.save() + + # Delete the .jpg afterwards + if os.path.isfile(full_file_path): + os.remove(full_file_path) + return True + except Exception as ex: + logging.exception(ex) + logging.info("Failed to save the plate image") + EmailNotificationSettings.send("App", "Plate Image Save Failed", + "Failed to save plate image into the database." + "\n\nAgent UID: {}\nIMG UID: {}\nException: {}".format(agent_uid, img_uuid, + ex)) + # Delete the jpg if something happens above + # if os.path.isfile(full_file_path): + # os.remove(full_file_path) + + raise Exception(ex) + elif not agent.enabled: + logging.info("Agent is disabled.") + + +def focus_camera(camera_id: int): + # Make the application context available here. This function is forked into a separate process and the database + # connections needs to be reintroduced. + app = create_app(app_config) + app.app_context().push() + + # Run in an infinite loop unless if an administrator disables forced focus & zoom checks + while True: + # Get camera IP/hostname, port, username, password, etc. + # We should always get the settings for each attempt, camera settings may have changed since last check. + camera = CameraSettings.filter_by_camera_id(camera_id) + + # Stop if camera is not found in the database. + if camera is None: + raise Exception("Camera {} not found in database".format(camera_id)) + + # Check if the camera is enabled to be forced focused & zoomed + if not camera.enable: + raise Exception("Camera {} enable setting is turned off... exiting".format(camera.camera_id)) + + if camera.manufacturer == "Dahua": + dahua_if = Dahua(camera.camera_label, camera_id, camera.username, camera.password, camera.hostname, + camera.port, + camera.focus, camera.zoom, https=False) + try: + dahua_if.set_focus_and_zoom() + except Exception as ex: + logging.error("Could not set camera focus and zoom.\nException: {}".format(ex)) + + # Sleep + for second in range(camera.focus_zoom_interval_check * 60): + time.sleep(1) + else: + raise Exception("Unsupported camera manufacturer '{}' for camera ID# {}.".format(camera.manufacturer, + camera.camera_id)) + + +def send_alert(custom_alert_id: int): + # Make the application context available here. This function is forked into a separate process and the database + # connections needs to be reintroduced. + app = create_app(app_config) + app.app_context().push() + + try: + custom_alert = CustomAlert.filter_by_id(custom_alert_id) + alpr_group = ALPRGroup.filter_by_id(custom_alert.alpr_group_id) + + if custom_alert: + if alpr_group: + report_settings = GeneralSettings.get_settings() + submitted_user = User.find_by_id(custom_alert.submitted_by_user_id) + submitted_user_profile = UserProfile.find_by_user_id(custom_alert.submitted_by_user_id) + + email = Email() + email.tag = Tag.ALERT + email.subject = "{} Match! - OpenALPR-Webhook".format(custom_alert.license_plate) + # Add a publicly accessible URL + email.body = "🚨 Custom Alert: {}\n\n{}\n\nLocation/Agent: {}\n\nCamera: {}\n\nOrganization: {}\n\n" \ + "OpenALPR-Webhook".\ + format(custom_alert.license_plate, custom_alert.description, + alpr_group.web_server_config['agent_label'], + alpr_group.web_server_config['camera_label'], report_settings.org_name) + + # Send email alert to receipt first + if helper.are_valid_email_recipients(submitted_user.email): + email.recipients = submitted_user.email + email.send() + + sms = SMS() + sms.msg = "🚨 Custom Alert: {}\n\n{}\n\nLocation/Agent: {}\n\nCamera: {}\n\nOrganization: {}\n\n" \ + "OpenALPR-Webhook".format(custom_alert.license_plate, custom_alert.description, + alpr_group.web_server_config['agent_label'], + alpr_group.web_server_config['camera_label'], + report_settings.org_name) + + # Send email alert to receipt first + if helper.are_valid_sms_recipients(submitted_user_profile.phone): + sms.recipients = submitted_user_profile.phone + sms.send() + + # Send to additional recipients + if custom_alert.notify_user_ids is not None: + for id in custom_alert.notify_user_ids: + user = User.find_by_id(id) + user_profile = UserProfile.find_by_user_id(id) + if helper.are_valid_email_recipients(user.email): + email.recipients = user.email + email.send() + if helper.are_valid_sms_recipients(user_profile.phone): + sms.recipients = user_profile.phone + sms.send() + else: + print("ALPR Group #{} was not found in the database.".format(custom_alert.alpr_group_id)) + logging.exception("ALPR Group #{} was not found in the database.".format(custom_alert.alpr_group_id)) + raise Exception("ALPR Group #{} was not found in the database.".format(custom_alert.alpr_group_id)) + else: + print("Custom alert #{} was not found in the database.".format(custom_alert_id)) + logging.exception("Custom alert #{} was not found in the database.".format(custom_alert_id)) + raise Exception("Custom alert #{} was not found in the database.".format(custom_alert_id)) + + except Exception as ex: + print(ex) + logging.exception(ex) diff --git a/apps/alpr/routes/__init__.py b/apps/alpr/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/alpr/routes/alert/__init__.py b/apps/alpr/routes/alert/__init__.py new file mode 100644 index 0000000..fac626b --- /dev/null +++ b/apps/alpr/routes/alert/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +blueprint = Blueprint( + 'alert', + __name__, + url_prefix='/alert' +) diff --git a/apps/alpr/routes/alert/routes.py b/apps/alpr/routes/alert/routes.py new file mode 100644 index 0000000..4ed4ef9 --- /dev/null +++ b/apps/alpr/routes/alert/routes.py @@ -0,0 +1,81 @@ +import datetime + +from flask import render_template +from flask_login import login_required, current_user + +from apps import helpers +from apps.alpr.models.alpr_alert import ALPRAlert +from apps.alpr.models.cache import CameraCache, AgentCache +from apps.alpr.models.custom_alert import CustomAlert +from apps.alpr.models.settings import GeneralSettings +from apps.alpr.routes.alert import blueprint +from apps.authentication.models import UserProfile, User + + +@blueprint.route('/custom/', methods=["GET"]) +@login_required +def custom_alert(id): + alert = CustomAlert.filter_by_id_and_beautify(id) + dt = helpers.Timezone(current_user) + + if alert is None: + return render_template('home/page-404.html') + else: + cached_agent = AgentCache.filter_by_agent_uid(alert['agent_uid']) + cached_camera = CameraCache.filter_by_id_and_beautify(alert['camera_id']) + return render_template('home/custom-alert.html', segment='alerts-custom-alert', alert=alert, + date=dt.astimezone(datetime.datetime.utcnow()), + user_profile=UserProfile.find_by_user_id(current_user.id), cached_agent=cached_agent, + cached_camera=cached_camera, settings=GeneralSettings.get_settings(), + users=User.get_list_of_users_w_user_profiles()) + + +@blueprint.route('/custom/print/', methods=["GET"]) +@login_required +def print_custom_alert(id): + alert = ALPRAlert.filter_by_id_and_beautify(id) + dt = helpers.Timezone(current_user) + + if alert is None: + return render_template('home/page-404.html') + else: + cached_agent = AgentCache.filter_by_agent_uid(alert['agent_uid']) + cached_camera = CameraCache.filter_by_id_and_beautify(alert['camera_id']) + return render_template('home/custom-alert.html', segment='alerts-custom-print-alert', alert=alert, + date=dt.astimezone(datetime.datetime.utcnow()), + user_profile=UserProfile.find_by_user_id(current_user.id), cached_agent=cached_agent, + cached_camera=cached_camera, settings=GeneralSettings.get_settings()) + + +@blueprint.route('/rekor/', methods=["GET"]) +@login_required +def alpr_alert(id): + alert = ALPRAlert.filter_by_id_and_beautify(id) + dt = helpers.Timezone(current_user) + + if alert is None: + return render_template('home/page-404.html') + else: + cached_agent = AgentCache.filter_by_agent_uid(alert['agent_uid']) + cached_camera = CameraCache.filter_by_id_and_beautify(alert['camera_id']) + return render_template('home/alert.html', segment='alerts-rekor-alert', alert=alert, + date=dt.astimezone(datetime.datetime.utcnow()), + user_profile=UserProfile.find_by_user_id(current_user.id), cached_agent=cached_agent, + cached_camera=cached_camera, settings=GeneralSettings.get_settings()) + + +@blueprint.route('/rekor/print/', methods=["GET"]) +@login_required +def print_alpr_alert(id): + alert = ALPRAlert.filter_by_id_and_beautify(id) + dt = helpers.Timezone(current_user) + + if alert is None: + return render_template('home/page-404.html') + else: + cached_agent = AgentCache.filter_by_agent_uid(alert['agent_uid']) + cached_camera = CameraCache.filter_by_id_and_beautify(alert['camera_id']) + return render_template('home/alert-print.html', segment='alerts-rekor-print-alert', alert=alert, + date=dt.astimezone(datetime.datetime.utcnow()), + user_profile=UserProfile.find_by_user_id(current_user.id), cached_agent=cached_agent, + cached_camera=cached_camera, settings=GeneralSettings.get_settings()) diff --git a/apps/alpr/routes/alerts/__init__.py b/apps/alpr/routes/alerts/__init__.py new file mode 100644 index 0000000..9bcba18 --- /dev/null +++ b/apps/alpr/routes/alerts/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +blueprint = Blueprint( + 'alerts', + __name__, + url_prefix='/alerts' +) diff --git a/apps/alpr/routes/alerts/custom/__init__.py b/apps/alpr/routes/alerts/custom/__init__.py new file mode 100644 index 0000000..12dde56 --- /dev/null +++ b/apps/alpr/routes/alerts/custom/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +blueprint = Blueprint( + 'custom_alerts', + __name__, + url_prefix='/alerts/custom' +) diff --git a/apps/alpr/routes/alerts/custom/routes.py b/apps/alpr/routes/alerts/custom/routes.py new file mode 100644 index 0000000..25e7bfd --- /dev/null +++ b/apps/alpr/routes/alerts/custom/routes.py @@ -0,0 +1,131 @@ +import logging + +from flask import request, jsonify +from flask_login import current_user, login_required +import apps.helpers as helper + +from apps import db +from apps.alpr.models.alpr_group import ALPRGroup +from apps.alpr.models.custom_alert import CustomAlert +from apps.alpr.routes.alerts.custom import blueprint +from apps.authentication.models import User, RoleType +from apps.helpers import message + + +@blueprint.route('/add', methods=["PUT"]) +@login_required +def add(): + data = request.form + + alpr_group_id = data.get('alpr_group_id') + license_plate = data.get('license_plate') + region_match = bool(data.get('region_match')) + description = data.get('description') + username = current_user.username + notify_user_ids = str(data.get('notify_user_ids')).split(',') + + user = User.find_by_username(username) + + if notify_user_ids != "null" and user.status != RoleType['ADMIN']: + return jsonify({'error': message['illegal_access']}), 404 + + custom_alert = CustomAlert.filter_by_license_plate(license_plate) + if custom_alert is None or custom_alert.submitted_by_user_id != user.id: + try: + custom_alert = CustomAlert() + custom_alert.alpr_group_id = alpr_group_id + custom_alert.license_plate = license_plate + custom_alert.region_match = region_match + custom_alert.description = description + custom_alert.notify_user_ids = notify_user_ids + custom_alert.submitted_by_user_id = user.id + custom_alert.save() + return jsonify({'message': message['custom_alert_added_successfully']}), 200 + except Exception as ex: + logging.exception(ex) + return jsonify({'error': message['unknown_error_occurred']}), 404 + else: + return jsonify({'error': message['duplicate_custom_alert']}), 404 + + +@blueprint.route('/edit', methods=["PUT"]) +@login_required +def edit(): + data = request.form + + id = int(data.get('id')) + region_match = bool(data.get('region_match')) + description = data.get('description') + username = current_user.username + notify_user_ids = data.get('notify_user_ids') if data.get('notify_user_ids') == 'null' else str(data.get('notify_user_ids')).split(',') + + user = User.find_by_username(username) + + if notify_user_ids != "null" and user.status != RoleType['ADMIN']: + return jsonify({'error': message['illegal_access']}), 404 + + custom_alert = CustomAlert.filter_by_id(id) + if custom_alert: + try: + custom_alert.region_match = region_match + custom_alert.description = description + if notify_user_ids != 'null': + custom_alert.notify_user_ids = notify_user_ids + custom_alert.save() + return jsonify({'message': message['custom_alert_updated_successfully']}), 200 + except Exception as ex: + logging.exception(ex) + return jsonify({'error': message['unknown_error_occurred']}), 404 + else: + return jsonify({'error': message['unknown_error_occurred']}), 404 + + +@blueprint.route('/query', methods=["GET"]) +@login_required +def query(): + user = User.find_by_username(current_user.username) + query = CustomAlert.query.order_by(CustomAlert.id.desc()) + query = query.filter_by(submitted_by_user_id=user.id) + + # search filter + search = request.args.get('search') + if search: + query = query.filter(db.or_(CustomAlert.license_plate.like(f'%{search}%'), CustomAlert.description.like(f'%{search}%'))) + total = query.count() + + # pagination + start = request.args.get('start', type=int, default=-1) + length = request.args.get('length', type=int, default=-1) + if start != -1 and length != -1: + query = query.offset(start).limit(length) + + dt = helper.Timezone(current_user) + data = [] + for record in query: + alpr_group = ALPRGroup.filter_by_id(record.alpr_group_id) + data.append({ + 'id': record.id, + 'site': alpr_group.web_server_config['agent_label'], + 'camera': alpr_group.web_server_config['camera_label'], + 'plate_number': record.license_plate, + 'plate_crop_jpeg': alpr_group.best_plate['plate_crop_jpeg'], + 'direction': alpr_group.travel_direction_class_tag, + 'confidence': alpr_group.best_confidence_percent, + 'time': dt.astimezone(alpr_group.epoch_start) + }) + + # response + return { + 'data': data, + 'total': total, + } + + +@blueprint.route('//choices.js', methods=["GET"]) +@login_required +def setChoices(id): + custom_alert = CustomAlert.filter_by_id(int(id)) + if custom_alert: + return jsonify(helper.setChoices(current_user, custom_alert.notify_user_ids)) + else: + return jsonify({'error': 'Custom alert not found'}), 404 diff --git a/apps/alpr/routes/alerts/rekor/__init__.py b/apps/alpr/routes/alerts/rekor/__init__.py new file mode 100644 index 0000000..1a6e3f2 --- /dev/null +++ b/apps/alpr/routes/alerts/rekor/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +blueprint = Blueprint( + 'alpr_alerts', + __name__, + url_prefix='/alerts/rekor' +) diff --git a/apps/alpr/routes/alerts/rekor/routes.py b/apps/alpr/routes/alerts/rekor/routes.py new file mode 100644 index 0000000..1a9e9d2 --- /dev/null +++ b/apps/alpr/routes/alerts/rekor/routes.py @@ -0,0 +1,45 @@ +from flask import request +from flask_login import current_user, login_required + +from apps import db +from apps.alpr.models.alpr_alert import ALPRAlert +from apps.alpr.routes.alerts.rekor import blueprint +from apps import helpers as helper + + +@blueprint.route('/query', methods=["GET"]) +@login_required +def query(): + query = ALPRAlert.query.order_by(ALPRAlert.id.desc()) + + # search filter + search = request.args.get('search') + if search: + query = query.filter(db.or_(ALPRAlert.plate_number.like(f'%{search}%'), ALPRAlert.site_name.like(f'%{search}%'))) + total = query.count() + + # pagination + start = request.args.get('start', type=int, default=-1) + length = request.args.get('length', type=int, default=-1) + if start != -1 and length != -1: + query = query.offset(start).limit(length) + + dt = helper.Timezone(current_user) + data = [] + for record in query: + data.append({ + 'id': record.id, + 'site_name': record.site_name, + 'camera_name': record.camera_name, + 'plate_number': record.plate_number, + 'plate_crop_jpeg': record.group['best_plate']['plate_crop_jpeg'], + 'travel_direction_class_tag': record.travel_direction_class_tag, + 'best_confidence_percent': record.best_confidence_percent, + 'epoch_time': dt.astimezone(record.epoch_time) + }) + + # response + return { + 'data': data, + 'total': total, + } diff --git a/apps/alpr/routes/alerts/routes.py b/apps/alpr/routes/alerts/routes.py new file mode 100644 index 0000000..9c4ab0a --- /dev/null +++ b/apps/alpr/routes/alerts/routes.py @@ -0,0 +1,16 @@ +from flask import render_template +from flask_login import login_required + +from apps.alpr.routes.alerts import blueprint + + +@blueprint.route('/custom', methods=["GET"]) +@login_required +def custom(): + return render_template('home/custom-alerts.html', segment='alerts-custom-alerts') + + +@blueprint.route('/rekor', methods=["GET"]) +@login_required +def rekor(): + return render_template('home/alerts.html', segment='alerts-rekor-scout') diff --git a/apps/alpr/routes/capture/__init__.py b/apps/alpr/routes/capture/__init__.py new file mode 100644 index 0000000..7db5c0a --- /dev/null +++ b/apps/alpr/routes/capture/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +blueprint = Blueprint( + 'capture', + __name__, + url_prefix='/capture' +) diff --git a/apps/alpr/routes/capture/routes.py b/apps/alpr/routes/capture/routes.py new file mode 100644 index 0000000..155b4bc --- /dev/null +++ b/apps/alpr/routes/capture/routes.py @@ -0,0 +1,44 @@ +import datetime + +from flask import render_template +from flask_login import login_required, current_user + +from apps import helpers +from apps.alpr.models.alpr_group import ALPRGroup +from apps.alpr.models.cache import CameraCache +from apps.alpr.models.settings import GeneralSettings +from apps.alpr.routes.capture import blueprint +from apps.authentication.models import UserProfile, User + + +@blueprint.route('/', methods=["GET"]) +@login_required +def plate(id): + license_plate = ALPRGroup.filter_by_id_and_beautify(id) + dt = helpers.Timezone(current_user) + user_profile = UserProfile.find_by_user_id(current_user.id) + + if license_plate is None: + return render_template('home/page-404.html') + else: + cached_camera = CameraCache.filter_by_id_and_beautify(license_plate['camera_id']) + return render_template('home/capture.html', segment='search', license_plate=license_plate, + date=dt.astimezone(datetime.datetime.utcnow()), + user_profile=user_profile, cached_camera=cached_camera, + settings=GeneralSettings.get_settings(), users=User.get_list_of_users_w_user_profiles()) + + +@blueprint.route('/print/', methods=["GET"]) +@login_required +def print_plate(id): + license_plate = ALPRGroup.filter_by_id_and_beautify(id) + dt = helpers.Timezone(current_user) + + if license_plate is None: + return render_template('home/page-404.html') + else: + cached_camera = CameraCache.filter_by_id_and_beautify(license_plate['camera_id']) + return render_template('home/capture-print.html', segment='search', license_plate=license_plate, + date=dt.astimezone(datetime.datetime.utcnow()), + user_profile=UserProfile.find_by_user_id(current_user.id), cached_camera=cached_camera, + settings=GeneralSettings.get_settings()) diff --git a/apps/alpr/routes/search/__init__.py b/apps/alpr/routes/search/__init__.py new file mode 100644 index 0000000..ae32afc --- /dev/null +++ b/apps/alpr/routes/search/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +blueprint = Blueprint( + 'search', + __name__, + url_prefix='/search' +) diff --git a/apps/alpr/routes/search/routes.py b/apps/alpr/routes/search/routes.py new file mode 100644 index 0000000..ba7a1d4 --- /dev/null +++ b/apps/alpr/routes/search/routes.py @@ -0,0 +1,58 @@ +from flask import render_template, request +from flask_login import login_required, current_user +from sqlalchemy import func + +from apps import db, helpers +from apps.alpr.models.alpr_group import ALPRGroup +from apps.alpr.routes.search import blueprint + + +@blueprint.route('/', methods=["GET"]) +@login_required +def search(): + return render_template('home/search.html', segment='search') + + +@blueprint.route('/query', methods=["GET"]) +@login_required +def query(): + query = ALPRGroup.query.order_by(ALPRGroup.id.desc()) + + # search filter + search = request.args.get('search') + if search: + query = query.filter(db.or_(ALPRGroup.best_plate_number.like(f'%{search}%'))) + + total = query.count() + + # pagination + start = request.args.get('start', type=int, default=-1) + length = request.args.get('length', type=int, default=-1) + if start != -1 and length != -1: + query = query.offset(start).limit(length) + + dt = helpers.Timezone(current_user) + data = [] + for record in query: + data.append({ + 'id': record.id, + 'site': record.web_server_config['agent_label'], + 'camera': record.web_server_config['camera_label'], + 'plate_number': record.best_plate_number, + 'plate_crop_jpeg': record.best_plate['plate_crop_jpeg'], + 'direction': record.travel_direction_class_tag, + 'confidence': record.best_confidence_percent, + 'time': dt.astimezone(record.epoch_start) + }) + + # response + return { + 'data': data, + 'total': total, + } + + +def get_count(q): + count_q = q.statement.with_only_columns([func.count()]).order_by(None) + count = q.session.execute(count_q).scalar() + return count diff --git a/apps/alpr/routes/settings/__init__.py b/apps/alpr/routes/settings/__init__.py new file mode 100644 index 0000000..4f30871 --- /dev/null +++ b/apps/alpr/routes/settings/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +blueprint = Blueprint( + 'settings', + __name__, + url_prefix='/settings' +) diff --git a/apps/alpr/routes/settings/agents/__init__.py b/apps/alpr/routes/settings/agents/__init__.py new file mode 100644 index 0000000..584bbb9 --- /dev/null +++ b/apps/alpr/routes/settings/agents/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +blueprint = Blueprint( + 'agents', + __name__, + url_prefix='/settings/agents' +) diff --git a/apps/alpr/routes/settings/agents/routes.py b/apps/alpr/routes/settings/agents/routes.py new file mode 100644 index 0000000..0b1313c --- /dev/null +++ b/apps/alpr/routes/settings/agents/routes.py @@ -0,0 +1,118 @@ +import logging +from flask import render_template, request, jsonify +from flask_login import current_user, login_required +from apps import db +from apps.alpr.models.settings import AgentSettings +from apps.alpr.routes.settings.agents import blueprint +from apps.authentication.routes import ROLE_ADMIN +import apps.helpers as helper +from apps.helpers import message + + +@blueprint.route('/search', methods=["GET"]) +@login_required +def search(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + query = AgentSettings.query + + # search filter + search = request.args.get('search') + if search: + query = query.filter(db.or_(AgentSettings.agent_uid.like(f'%{search}%'), AgentSettings.agent_label.like(f'%{search}%'))) + total = query.count() + + # pagination + start = request.args.get('start', type=int, default=-1) + length = request.args.get('length', type=int, default=-1) + if start != -1 and length != -1: + query = query.offset(start).limit(length) + + dt = helper.Timezone(current_user) + data = [] + for record in query: + data.append({ + 'id': record.id, + 'agent_uid': record.agent_uid, + 'agent_label': record.agent_label, + 'ip_hostname': record.ip_hostname, + 'port': record.port, + 'created': dt.astimezone(record.created), + 'last_seen': dt.astimezone(record.last_seen) + }) + + # response + return { + 'data': data, + 'total': total, + } + + +@blueprint.route('/edit', methods=['GET', 'PUT']) +@login_required +def edit(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + if request.method == 'GET': + agent = AgentSettings.filter_by_id(request.args.get('id')) + + dt = helper.Timezone(current_user) + + if agent: + context = {'id': agent.id, 'enabled': agent.enabled, 'agent_uid': agent.agent_uid, + 'agent_label': agent.agent_label, 'ip_hostname': agent.ip_hostname, 'port': agent.port, + 'created': dt.astimezone(agent.created), 'last_seen': dt.astimezone(agent.last_seen) + } + return jsonify(context), 200 + else: + return jsonify({'error': message['record_not_found']}), 404 + + if request.method == 'PUT': + data = request.form + + # Validate before proceeding + ip_hostname = data.get('ip_hostname') + port = data.get('port') + enabled = bool(data.get('enabled')) + + # Check to if we can even put the values through the validators + if len(ip_hostname) == 0: + return jsonify({'error': message['ip_hostname_not_valid']}), 404 + if port is None: + return jsonify({'error': message['port_not_valid']}), 404 + else: + # Cast it as an int + port = int(port) + + is_valid_hostname = helper.is_valid_hostname(ip_hostname) + is_valid_ip = helper.is_valid_ip(ip_hostname) + + if not is_valid_hostname and not is_valid_ip: + return jsonify({'error': message['ip_hostname_not_valid']}), 404 + if not helper.is_valid_port(port): + return jsonify({'error': message['port_not_valid']}), 404 + + # Lets find the agent in the db + agent = AgentSettings.filter_by_id(data.get('id')) + + if agent: + try: + # Update it! + agent.ip_hostname = ip_hostname + agent.port = port + agent.enabled = enabled + agent.save() + + # Reload workers and queues. Someone may have enabled an agent which will require a worker and queue + # dedicated to that agent + # reload_wqs() + + except Exception as ex: + logging.exception(ex) + return jsonify({'error': message['could_not_process']}), 404 + + return jsonify({'message': message['agent_updated']}), 200 + else: + return jsonify({'error': message['agent_not_found']}), 404 diff --git a/apps/alpr/routes/settings/cameras/__init__.py b/apps/alpr/routes/settings/cameras/__init__.py new file mode 100644 index 0000000..bcddf91 --- /dev/null +++ b/apps/alpr/routes/settings/cameras/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +blueprint = Blueprint( + 'cameras', + __name__, + url_prefix='/settings/cameras' +) diff --git a/apps/alpr/routes/settings/cameras/manufacturers/Dahua.py b/apps/alpr/routes/settings/cameras/manufacturers/Dahua.py new file mode 100644 index 0000000..b88af55 --- /dev/null +++ b/apps/alpr/routes/settings/cameras/manufacturers/Dahua.py @@ -0,0 +1,126 @@ +import logging +import time + +import requests +from requests.auth import HTTPDigestAuth + + +class Dahua: + label = "" + id = "" + proto = "" + username = "" + password = "" + ip_hostname = "" + port = "" + focus = "" + zoom = "" + + def __init__(self, label, id, username: str, password: str, ip_hostname: str, port, focus: str, zoom: str, + https=False): + if https: + self.proto = "https://" + else: + self.proto = "http://" # noqa + + self.label = label + self.id = id + self.username = username + self.password = password + self.ip_hostname = ip_hostname + self.port = str(port) + self.focus = focus + self.zoom = zoom + + def auto_focus(self) -> bool: + """ + Forces the camera to focus automatically. + :return: True if no exception raised. + """ + + try: + url = self.proto + self.ip_hostname + ":" + self.port + "/cgi-bin/devVideoInput.cgi?action=autoFocus" + response = requests.get(url, auth=HTTPDigestAuth(self.username, self.password)) + logging.debug("Camera {}/{} HTTP response status code: {}".format(self.label, self.id, + response.status_code)) + if response.text == "OK\r\n": + return True + else: + return False + except Exception as ex: + logging.exception(ex) + raise ex + + def _convert_to_dict(self, text: str) -> dict: + """ + Converts a return/new line string containing keys and values into a dictionary of strings. + :param text: A return/new line string containing keys and values. + :return: A dictionary of strings. + """ + + dictionary = {} + new_lines = text.split('\n') + new_lines.remove('') + + for line in new_lines: + key_value = line.replace('\r', '') + key_value = key_value.split('=') + dictionary[key_value[0]] = key_value[1] + + return dictionary + + def _getFocusStatus(self) -> dict: + """ + Calls getFocusStatus from the camera to get the following values: status.Focus, status.FocusMotorSteps, + status.LenAdjustStatus, status.ResetResult, status.Status, status.Zoom, and status.ZoomMotorSteps + :return: A dictionary containing the keys previously mentioned + """ + try: + url = self.proto + self.ip_hostname + ":" + self.port + "/cgi-bin/devVideoInput.cgi?action=getFocusStatus" + return self._convert_to_dict(requests.get(url, auth=HTTPDigestAuth(self.username, self.password)).text) + except Exception as ex: + raise ex + + def get_focus_zoom_values(self) -> str: + try: + values = self._getFocusStatus() + return "Focus: " + values['status.Focus'] + " Zoom: " + values['status.Zoom'] + except Exception as ex: + raise ex + + def set_focus_and_zoom(self) -> bool: + """ + Forces the camera to zoom and focus to the specified values by calling adjustFocus&focus=X&zoom=Y 5 times. + autoFocus is not used because focusing at night with little to no reference points causes the camera to unfocus. + :return: True if current focus & zoom match the specified focus & zoom values, else False. + """ + + try: + values = self._getFocusStatus() + # Return if it's already in focus and zoomed. + if values['status.Focus'] == self.focus and values['status.Zoom'] == self.zoom: + logging.debug("Camera {}/{} already focused and zoomed".format(self.label, self.id)) + return True + + url = self.proto + self.ip_hostname + ":" + self.port + \ + "/cgi-bin/devVideoInput.cgi?action=adjustFocus&focus=" + self.focus + "&zoom=" + self.zoom + try: + for i in range(5): + response = requests.get(url, auth=HTTPDigestAuth(self.username, self.password)) + time.sleep(1) + logging.debug("Camera {}/{} HTTP response status code: {}".format(self.label, self.id, + response.status_code)) + except Exception as ex: + logging.exception(ex) + + # Check to see if it's focused and zoomed + values = self._getFocusStatus() + if values['status.Focus'] == self.focus and values['status.Zoom'] == self.zoom: + return True + else: + logging.error("Could not set focus and zoom. focus={}, zoom={}, status.Focus={}, status.Zoom={}". + format(self.focus, self.zoom, values['status.Focus'], values['status.Zoom'])) + return False + except Exception as ex: + logging.exception(ex) + raise ex diff --git a/apps/alpr/routes/settings/cameras/manufacturers/__init__.py b/apps/alpr/routes/settings/cameras/manufacturers/__init__.py new file mode 100644 index 0000000..dc31682 --- /dev/null +++ b/apps/alpr/routes/settings/cameras/manufacturers/__init__.py @@ -0,0 +1,22 @@ +import os + + +def get_camera_manufacturers() -> "[list]": + """ + Get a list of camera manufacturers based from the objects found in + apps/alpr/routes/settings/cameras/manufacturers/.py + :return: Returns a list of strings containing manufacturers. + """ + manufacturers = [] + files = os.listdir("apps/alpr/routes/settings/cameras/manufacturers") + + # Remove non-manufacturers + files.remove("__init__.py") + files.remove("__pycache__") + + # Remove file extensions + for file in files: + file = file.split('.') + manufacturers.append(file[0]) + + return manufacturers diff --git a/apps/alpr/routes/settings/cameras/routes.py b/apps/alpr/routes/settings/cameras/routes.py new file mode 100644 index 0000000..abee873 --- /dev/null +++ b/apps/alpr/routes/settings/cameras/routes.py @@ -0,0 +1,272 @@ +import logging +from flask import render_template, request, jsonify +from flask_login import current_user, login_required + +from apps import db +from apps.alpr.models.cache import CameraCache +from apps.alpr.models.settings import CameraSettings +from apps.alpr.routes.settings.cameras import blueprint +from apps.alpr.routes.settings.cameras.manufacturers.Dahua import Dahua +from apps.authentication.routes import ROLE_ADMIN +from apps.helpers import message +import apps.helpers as helper + + +@blueprint.route('/edit', methods=['GET', 'POST']) +@login_required +def edit(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + if request.method == 'GET': + camera = CameraSettings.filter_by_id(request.args.get('id')) + cache = CameraCache.filter_by_camera_id(camera.camera_id) + + if cache is None: + gps_latitude = -1 + gps_longitude = -1 + else: + gps_latitude = cache.gps_latitude + gps_longitude = cache.gps_longitude + + dt = helper.Timezone(current_user) + + if camera: + context = {'id': camera.id, 'camera_id': camera.camera_id, 'camera_label': camera.camera_label, + 'hostname': camera.hostname, 'port': camera.port, 'username': camera.username, + 'password': camera.password, 'focus': camera.focus, 'zoom': camera.zoom, + 'focus_zoom_interval_check': camera.focus_zoom_interval_check, + 'notify_on_failed_interval_check': camera.notify_on_failed_interval_check, + 'manufacturer': camera.manufacturer, 'enable': camera.enable, + 'created': dt.astimezone(camera.created), 'last_seen': dt.astimezone(camera.last_seen), + 'gps_latitude': gps_latitude, 'gps_longitude': gps_longitude + } + return jsonify(context), 200 + else: + return jsonify({'error': message['record_not_found']}), 404 + + if request.method == 'POST': + return_save = save() + if return_save == "ip_hostname_not_valid": + return jsonify({'error': message['ip_hostname_not_valid']}), 404 + elif return_save == "port_not_valid": + return jsonify({'error': message['port_not_valid']}), 404 + elif return_save == "ip_hostname_not_valid": + return jsonify({'error': message['ip_hostname_not_valid']}), 404 + elif return_save == "port_not_valid": + return jsonify({'error': message['port_not_valid']}), 404 + elif return_save == "could_not_process": + return jsonify({'error': message['could_not_process']}), 404 + elif return_save == "camera_updated": + return jsonify({'message': message['camera_updated']}), 200 + elif return_save == "camera_not_found": + return jsonify({'error': message['camera_not_found']}), 404 + + +@blueprint.route('/get/focus_zoom', methods=['POST']) +@login_required +def get_focus_zoom(): + return_save = save() + if return_save == "ip_hostname_not_valid": + return jsonify({'error': message['ip_hostname_not_valid']}), 404 + elif return_save == "port_not_valid": + return jsonify({'error': message['port_not_valid']}), 404 + elif return_save == "ip_hostname_not_valid": + return jsonify({'error': message['ip_hostname_not_valid']}), 404 + elif return_save == "port_not_valid": + return jsonify({'error': message['port_not_valid']}), 404 + elif return_save == "could_not_process": + return jsonify({'error': message['could_not_process']}), 404 + elif return_save == "camera_not_found": + return jsonify({'error': message['camera_not_found']}), 404 + elif return_save == "camera_updated": + data = request.form + # Lets find the camera in the db + camera = CameraSettings.filter_by_id(data.get('id')) + + if camera.manufacturer == "Dahua": + camif = Dahua(camera.camera_label, camera.camera_id, camera.username, camera.password, camera.hostname, + str(camera.port), camera.focus, camera.zoom) + try: + focus_zoom_values = camif.get_focus_zoom_values() + except Exception as ex: + logging.exception(ex) + return jsonify({'error': message['camera_get_focus_zoom_issue']}), 404 + else: + return jsonify({'error': message['camera_unknown_manufacturer']}), 404 + + return jsonify({'message': focus_zoom_values}), 200 + + +def save(): + data = request.form + + # Validate before proceeding + hostname = data.get('hostname') + port = data.get('port') + username = data.get('username') + password = data.get('password') + focus = data.get('focus') + zoom = data.get('zoom') + focus_zoom_interval_check = data.get('focus_zoom_interval_check') + notify_on_failed_interval_check = bool(data.get('notify_on_failed_interval_check')) + manufacturer = data.get('manufacturer') + enable = data.get('enable') + + # Check to if we can even put the values through the validators + if len(hostname) == 0: + return "ip_hostname_not_valid" + if port is None: + return "port_not_valid" + else: + # Cast it as an int + port = int(port) + + is_valid_hostname = helper.is_valid_hostname(hostname) + is_valid_ip = helper.is_valid_ip(hostname) + + if not is_valid_hostname and not is_valid_ip: + return "ip_hostname_not_valid" + if not helper.is_valid_port(port): + return "port_not_valid" + + # Lets find the agent in the db + camera = CameraSettings.filter_by_id(data.get('id')) + + if camera: + try: + # Update it! + camera.hostname = hostname + camera.port = port + camera.username = username + camera.password = password + camera.focus = focus + camera.zoom = zoom + camera.focus_zoom_interval_check = focus_zoom_interval_check + camera.notify_on_failed_interval_check = notify_on_failed_interval_check + camera.manufacturer = manufacturer + camera.enable = bool(enable) + camera.save() + except Exception as ex: + logging.exception(ex) + return "could_not_process" + + return "camera_updated" + else: + return "camera_not_found" + + +@blueprint.route('/search', methods=["GET"]) +@login_required +def search(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + query = CameraSettings.query + + # search filter + search = request.args.get('search') + if search: + query = query.filter(db.or_(CameraSettings.camera_id.like(f'%{search}%'), CameraSettings.camera_label.like(f'%{search}%'))) + total = query.count() + + # pagination + start = request.args.get('start', type=int, default=-1) + length = request.args.get('length', type=int, default=-1) + if start != -1 and length != -1: + query = query.offset(start).limit(length) + + dt = helper.Timezone(current_user) + data = [] + for record in query: + data.append({ + 'id': record.id, + 'camera_id': record.camera_id, + 'camera_label': record.camera_label, + 'hostname': record.hostname, + 'port': record.port, + 'focus': record.focus, + 'zoom': record.zoom, + 'focus_zoom_interval_check': record.focus_zoom_interval_check, + 'last_seen': dt.astimezone(record.last_seen) + }) + + # response + return { + 'data': data, + 'total': total, + } + + +@blueprint.route('/set/focus_zoom', methods=['POST']) +@login_required +def set_focus_zoom(): + return_save = save() + if return_save == "ip_hostname_not_valid": + return jsonify({'error': message['ip_hostname_not_valid']}), 404 + elif return_save == "port_not_valid": + return jsonify({'error': message['port_not_valid']}), 404 + elif return_save == "ip_hostname_not_valid": + return jsonify({'error': message['ip_hostname_not_valid']}), 404 + elif return_save == "port_not_valid": + return jsonify({'error': message['port_not_valid']}), 404 + elif return_save == "could_not_process": + return jsonify({'error': message['could_not_process']}), 404 + elif return_save == "camera_not_found": + return jsonify({'error': message['camera_not_found']}), 404 + elif return_save == "camera_updated": + data = request.form + # Lets find the camera in the db + camera = CameraSettings.filter_by_id(data.get('id')) + + if camera.manufacturer == "Dahua": + camif = Dahua(camera.camera_label, camera.camera_id, camera.username, camera.password, camera.hostname, + str(camera.port), camera.focus, camera.zoom) + try: + focus_zoom_status = camif.set_focus_and_zoom() + except Exception: + return jsonify({'error': message['camera_set_focus_zoom_issue']}), 404 + else: + return jsonify({'error': message['camera_unknown_manufacturer']}), 404 + + if focus_zoom_status: + return jsonify({'message': message['camera_set_focus_zoom']}), 200 + else: + return jsonify({'error': message['camera_set_focus_zoom_failed']}), 404 + + +@blueprint.route('/auto_focus', methods=['POST']) +@login_required +def auto_focus(): + return_save = save() + if return_save == "ip_hostname_not_valid": + return jsonify({'error': message['ip_hostname_not_valid']}), 404 + elif return_save == "port_not_valid": + return jsonify({'error': message['port_not_valid']}), 404 + elif return_save == "ip_hostname_not_valid": + return jsonify({'error': message['ip_hostname_not_valid']}), 404 + elif return_save == "port_not_valid": + return jsonify({'error': message['port_not_valid']}), 404 + elif return_save == "could_not_process": + return jsonify({'error': message['could_not_process']}), 404 + elif return_save == "camera_not_found": + return jsonify({'error': message['camera_not_found']}), 404 + elif return_save == "camera_updated": + data = request.form + # Lets find the camera in the db + camera = CameraSettings.filter_by_id(data.get('id')) + + if camera.manufacturer == "Dahua": + camif = Dahua(camera.camera_label, camera.camera_id, camera.username, camera.password, camera.hostname, + str(camera.port), camera.focus, camera.zoom) + try: + auto_focus_zoom_status = camif.auto_focus() + except Exception: + return jsonify({'error': message['camera_auto_focus_issue']}), 404 + else: + return jsonify({'error': message['camera_unknown_manufacturer']}), 404 + + if auto_focus_zoom_status: + return jsonify({'message': message['camera_auto_focus']}), 200 + else: + return jsonify({'error': message['camera_auto_focus_failed']}), 404 diff --git a/apps/alpr/routes/settings/general/__init__.py b/apps/alpr/routes/settings/general/__init__.py new file mode 100644 index 0000000..888e774 --- /dev/null +++ b/apps/alpr/routes/settings/general/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +blueprint = Blueprint( + 'general', + __name__, + url_prefix='/settings/general' +) diff --git a/apps/alpr/routes/settings/general/routes.py b/apps/alpr/routes/settings/general/routes.py new file mode 100644 index 0000000..d33b7b4 --- /dev/null +++ b/apps/alpr/routes/settings/general/routes.py @@ -0,0 +1,169 @@ +import base64 +import logging +import os + +from flask import request, jsonify, render_template +from flask_login import login_required, current_user +from werkzeug.utils import secure_filename + +from apps import ip_ban_config +from apps.alpr.models.settings import GeneralSettings, default_org_logo, PostAuth +from apps.alpr.routes.settings.general import blueprint +from apps.authentication.routes import upload_folder_name, ROLE_ADMIN +from apps.helpers import unique_file_name, message + + +@blueprint.route('/edit/general', methods=['POST']) +@login_required +def edit_general_settings(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + if request.method == 'POST': + data = request.form + settings = GeneralSettings.get_settings() + + # Create a row of settings in case they weren't initialized previously on start up + if settings is None: + settings = GeneralSettings() + + if settings: + settings.public_url = data.get('public_url') + settings.save() + + return jsonify({'message': message['general_settings_saved']}), 200 + else: + return jsonify({'error': message['general_settings_not_saved']}), 404 + + +@blueprint.route('/edit/ipban', methods=['POST']) +@login_required +def edit_ipban_settings(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + if request.method == 'POST': + data = request.form + + settings = ip_ban_config + + if settings: + ipban_ban_count = int(data.get('ipban_ban_count')) + ipban_ban_seconds = int(data.get('ipban_ban_seconds')) + ipban_persist = bool(data.get('ipban_persist')) + ipban_ip_header = data.get('ipban_ip_header') + ipban_abuse_IPDB_config_report = bool(data.get('ipban_abuse_IPDB_config_report')) + ipban_abuse_IPDB_config_load = bool(data.get('ipban_abuse_IPDB_config_load')) + ipban_abuse_IPDB_config_key = data.get('ipban_abuse_IPDB_config_key') + + if ipban_ban_count <= 0: + return jsonify({'error': message['ipban_invalid_ban_count']}), 404 + if ipban_ban_seconds < 0: + return jsonify({'error': message['ipban_invalid_ban_seconds']}), 404 + if ipban_abuse_IPDB_config_report or ipban_abuse_IPDB_config_load: + if ipban_abuse_IPDB_config_key == "": + return jsonify({'error': message['ipban_key_needed_to_report_load']}), 404 + + settings.ban_count = str(ipban_ban_count) + settings.ban_seconds = str(ipban_ban_seconds) + settings.persist = str(ipban_persist) + settings.ip_header = str(ipban_ip_header) + settings.abuse_IPDB_config_report = str(ipban_abuse_IPDB_config_report) + settings.abuse_IPDB_config_load = str(ipban_abuse_IPDB_config_load) + settings.abuse_IPDB_config_key = ipban_abuse_IPDB_config_key + + settings.save() + + return jsonify({'message': message['ipban_settings_saved']}), 200 + else: + return jsonify({'error': message['ipban_settings_not_saved']}), 404 + + +@blueprint.route('/edit/report', methods=['POST']) +@login_required +def edit_report_settings(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + if request.method == 'POST': + data = request.form + logo = request.files.get('org_logo') + + settings = GeneralSettings.get_settings() + + # Create a row of settings in case they weren't initialized previously on start up + if settings is None: + settings = GeneralSettings() + + if settings: + # Change organization logo + if logo: + filename = unique_file_name(secure_filename(logo.filename)) + full_file_path = os.path.join(upload_folder_name, filename) + try: + logo.save(full_file_path) + with open(full_file_path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()) + settings.logo = encoded_string.decode("utf-8") + if os.path.isfile(full_file_path): + os.remove(full_file_path) + except Exception as ex: + if os.path.isfile(full_file_path): + os.remove(full_file_path) + logging.exception(ex) + return jsonify({'error': message['error_updating_brand_logo']}), 500 + + # Organization name + settings.org_name = data.get('org_name') + settings.save() + + return jsonify({'message': message['report_settings_saved']}), 200 + else: + return jsonify({'error': message['report_settings_not_saved']}), 404 + + +@blueprint.route('/reset/report', methods=['POST']) +@login_required +def reset_report_settings(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + if request.method == 'POST': + settings = GeneralSettings.get_settings() + + # Create a row of settings in case they weren't initialized previously on start up + if settings is None: + settings = GeneralSettings() + + if settings: + # Reset brand settings to default + settings.org_name = "OpenALPR-Webhook" + settings.logo = default_org_logo + settings.save() + + return jsonify({'message': message['report_settings_saved']}), 200 + else: + return jsonify({'error': message['report_settings_not_saved']}), 404 + + +@blueprint.route('/edit/post_auth', methods=['POST']) +@login_required +def edit_post_auth_settings(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + if request.method == 'POST': + data = request.form + settings = GeneralSettings.get_settings() + + # Create a row of settings in case they weren't initialized previously on start up + if settings is None: + settings = GeneralSettings() + + if settings: + settings.post_auth = PostAuth(int(data.get('post_auth'))) + settings.save() + + return jsonify({'message': message['post_auth_settings_saved']}), 200 + else: + return jsonify({'error': message['post_auth_settings_not_saved']}), 404 diff --git a/apps/alpr/routes/settings/maintenance/__init__.py b/apps/alpr/routes/settings/maintenance/__init__.py new file mode 100644 index 0000000..b4ed95e --- /dev/null +++ b/apps/alpr/routes/settings/maintenance/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +blueprint = Blueprint( + 'maintenance', + __name__, + url_prefix='/settings/maintenance' +) diff --git a/apps/alpr/routes/settings/maintenance/routes.py b/apps/alpr/routes/settings/maintenance/routes.py new file mode 100644 index 0000000..35d3c0c --- /dev/null +++ b/apps/alpr/routes/settings/maintenance/routes.py @@ -0,0 +1,132 @@ +import logging + +from flask import render_template, jsonify +from flask_login import login_required, current_user +from marshmallow import fields + +import apps.alpr.get as Get +from apps.alpr.models.alpr_alert import ALPRAlert +from apps.alpr.models.alpr_group import ALPRGroup +from apps.alpr.models.cache import Cache, Counter, CameraCache, AgentCache +from apps.alpr.models.vehicle import Vehicle +from apps.alpr.routes.settings.maintenance import blueprint +from apps.api.schemas.alpr_alert_schema import ALPRAlertSchema +from apps.api.schemas.alpr_group_schema import ALPRGroupSchema +from apps.api.schemas.vehicle_schema import VehicleSchema +from apps.api.service.alpr_alert_service import ALPRAlertService +from apps.api.service.alpr_group_service import ALPRGroupService +from apps.api.service.vehicle_service import VehicleService +from apps.authentication.models import User +from apps.authentication.routes import ROLE_ADMIN + + +@blueprint.route('/init/cache', methods=["GET"]) +@login_required +def init_cache_db(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + # Drop all rows from each table + Cache.query.delete() + AgentCache.query.delete() + CameraCache.query.delete() + Counter.query.delete() + + cache = Cache.filter_by_year() + if cache is None: + cache = Cache() + cache.init() + + return jsonify({'msg': 'cache_initiated'}), 200 + + +@blueprint.route('/import/db', methods=["GET"]) +@login_required +def import_db(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + # Schemas + alpr_alert_schema = ALPRAlertSchema() + alpr_group_schema = ALPRGroupSchema() + vehicle_schema = VehicleSchema() + + # Services + alpr_alert_service = ALPRAlertService() + alpr_group_service = ALPRGroupService() + vehicle_service = VehicleService() + + get = Get.Get() + group_collection = get.collection(database="group") + print("len(group_collection) = {}".format(len(group_collection))) + alert_collection = get.collection(database="alert") + print("len(alert_collection) = {}".format(len(alert_collection))) + vehicle_collection = get.collection(database="vehicle") + print("len(vehicle_collection) = {}".format(len(vehicle_collection))) + + alpr_group_counter = Counter.filter_by_key("alpr_group") + if alpr_group_counter is None: + alpr_group_counter = Counter("alpr_group") + + alpr_alert_counter = Counter.filter_by_key("alpr_alert") + if alpr_alert_counter is None: + alpr_alert_counter = Counter("alpr_alert") + + vehicle_counter = Counter.filter_by_key("vehicle") + if vehicle_counter is None: + vehicle_counter = Counter("vehicle") + + for i in range(len(group_collection)): + request_data = group_collection[i] + alpr_group_counter.one_up() + try: + validated_data = alpr_group_schema.load(request_data) + alpr_group_service.create(validated_data) + except Exception as ex: + alpr_group_counter.one_down() + print("transfer_db: (alpr_group) ex = {}".format(ex)) + print("transfer_db: (alpr_group) request_data = {}".format(request_data)) + for i in range(len(alert_collection)): + request_data = alert_collection[i] + alpr_alert_counter.one_up() + try: + validated_data = alpr_alert_schema.load(request_data) + alpr_alert_service.create(validated_data) + except Exception as ex: + alpr_alert_counter.one_down() + print("transfer_db: (alpr_alert) ex = {}".format(ex)) + print("transfer_db: (alpr_alert) request_data = {}".format(request_data)) + for i in range(len(vehicle_collection)): + request_data = vehicle_collection[i] + vehicle_counter.one_up() + try: + validated_data = vehicle_schema.load(request_data) + vehicle_service.create(validated_data) + except Exception as ex: + vehicle_counter.one_down() + print("transfer_db: (vehicle) ex = {}".format(ex)) + print("transfer_db: (vehicle) request_data = {}".format(request_data)) + + alpr_group_counter.save() + alpr_alert_counter.save() + vehicle_counter.save() + + # Rewrite the API_TOKEN to that of the super_admin + super_admin = User.find_by_id(1) + if super_admin: + alpr_group = ALPRGroup() + alpr_alert = ALPRAlert() + vehicle = Vehicle() + + for record in alpr_group.query: + record.custom_data['API_KEY'] = super_admin.api_token + for record in alpr_alert.query: + record.custom_data['API_KEY'] = super_admin.api_token + for record in vehicle.query: + record.custom_data['API_KEY'] = super_admin.api_token + + alpr_group.save() + alpr_alert.save() + vehicle.save() + + return jsonify({'msg': "Records migrated to SQLite!"}), 200 diff --git a/apps/alpr/routes/settings/maintenance/rq_dashboard/__init__.py b/apps/alpr/routes/settings/maintenance/rq_dashboard/__init__.py new file mode 100644 index 0000000..be43eb2 --- /dev/null +++ b/apps/alpr/routes/settings/maintenance/rq_dashboard/__init__.py @@ -0,0 +1,9 @@ +from flask import Blueprint + +blueprint = Blueprint( + "rq_dashboard", + __name__, + template_folder="templates", + static_folder="static", + url_prefix='/settings/maintenance/queue' +) diff --git a/apps/alpr/routes/settings/maintenance/rq_dashboard/legacy_config.py b/apps/alpr/routes/settings/maintenance/rq_dashboard/legacy_config.py new file mode 100644 index 0000000..7593bfc --- /dev/null +++ b/apps/alpr/routes/settings/maintenance/rq_dashboard/legacy_config.py @@ -0,0 +1,33 @@ +import warnings + + +LEGACY_CONFIG_OPTIONS = { + "REDIS_URL": "RQ_DASHBOARD_REDIS_URL", + "REDIS_HOST": "RQ_DASHBOARD_REDIS_HOST", + "REDIS_PORT": "RQ_DASHBOARD_REDIS_PORT", + "REDIS_PASSWORD": "RQ_DASHBOARD_REDIS_PASSWORD", + "REDIS_DB": "RQ_DASHBOARD_REDIS_DB", + "REDIS_SENTINELS": "RQ_DASHBOARD_REDIS_SENTINELS", + "REDIS_MASTER_NAME": "RQ_DASHBOARD_REDIS_MASTER_NAME", + "RQ_POLL_INTERVAL": "RQ_DASHBOARD_POLL_INTERVAL", + "WEB_BACKGROUND": "RQ_DASHBOARD_WEB_BACKGROUND", + "DELETE_JOBS": "RQ_DASHBOARD_DELETE_JOBS", +} + +warning_template = ( + "Configuration option {old_name} is depricated and will be removed in future versions. " + "Please use {new_name} instead." +) + + +def upgrade_config(app): + """ + Updates old configuration options with new ones throwing warnings to those who haven't upgraded yet. + """ + for old_name, new_name in LEGACY_CONFIG_OPTIONS.items(): + if old_name in app.config: + warnings.warn( + warning_template.format(old_name=old_name, new_name=new_name), + UserWarning, + ) + app.config[new_name] = app.config[old_name] \ No newline at end of file diff --git a/apps/alpr/routes/settings/maintenance/rq_dashboard/queue_functions.py b/apps/alpr/routes/settings/maintenance/rq_dashboard/queue_functions.py new file mode 100644 index 0000000..81121bf --- /dev/null +++ b/apps/alpr/routes/settings/maintenance/rq_dashboard/queue_functions.py @@ -0,0 +1,5 @@ +import requests + +def count_words_at_url(url): + resp = requests.get(url) + return len(resp.text.split()) diff --git a/apps/alpr/routes/settings/maintenance/rq_dashboard/routes.py b/apps/alpr/routes/settings/maintenance/rq_dashboard/routes.py new file mode 100644 index 0000000..f124450 --- /dev/null +++ b/apps/alpr/routes/settings/maintenance/rq_dashboard/routes.py @@ -0,0 +1,546 @@ +"""RQ Dashboard Flask Blueprint. + +Uses the standard Flask configuration mechanism e.g. to set the connection +parameters to REDIS. To keep the documentation and defaults all in once place +the default settings must be loaded from ``rq_dashboard.default_settings`` +e.g. as done in ``cli.py``. + +RQ Dashboard does not contain any built-in authentication mechanism because + + 1. it is the responsbility of the wider hosting app rather than a + particular blueprint, and + + 2. there are numerous ways of adding security orthogonally. + +As a quick-and-dirty convenience, the command line invocation in ``cli.py`` +provides the option to require HTTP Basic Auth in a few lines of code. + +""" +import os +import re +from functools import wraps +from math import ceil + +import arrow +from flask import ( + Blueprint, + current_app, + make_response, + render_template, + request, + send_from_directory, + url_for, +) +from redis_sentinel_url import connect as from_url +from rq import ( + VERSION as rq_version, + Queue, + Worker, + pop_connection, + push_connection, + requeue_job, +) +from rq.job import Job +from rq.registry import ( + DeferredJobRegistry, + FailedJobRegistry, + FinishedJobRegistry, + StartedJobRegistry, +) +from six import string_types + +from . import blueprint +from .legacy_config import upgrade_config +from .version import VERSION as rq_dashboard_version + + +@blueprint.before_app_first_request +def setup_rq_connection(): + # we need to do It here instead of cli, since It may be embeded + upgrade_config(current_app) + # Getting Redis connection parameters for RQ + redis_url = current_app.config.get("RQ_DASHBOARD_REDIS_URL") + if isinstance(redis_url, string_types): + current_app.config["RQ_DASHBOARD_REDIS_URL"] = (redis_url,) + _, current_app.redis_conn = from_url((redis_url,)[0]) + elif isinstance(redis_url, (tuple, list)): + _, current_app.redis_conn = from_url(redis_url[0]) + else: + raise RuntimeError("No Redis configuration!") + + +@blueprint.before_request +def push_rq_connection(): + new_instance_number = request.view_args.get("instance_number") + if new_instance_number is not None: + redis_url = current_app.config.get("RQ_DASHBOARD_REDIS_URL") + if new_instance_number < len(redis_url): + _, new_instance = from_url(redis_url[new_instance_number]) + else: + raise LookupError("Index exceeds RQ list. Not Permitted.") + else: + new_instance = current_app.redis_conn + push_connection(new_instance) + current_app.redis_conn = new_instance + + +@blueprint.teardown_request +def pop_rq_connection(exception=None): + pop_connection() + + +def jsonify(f): + @wraps(f) + def _wrapped(*args, **kwargs): + from flask import jsonify as flask_jsonify + + result_dict = f(*args, **kwargs) + return flask_jsonify(**result_dict), {"Cache-Control": "no-store"} + + return _wrapped + + +def serialize_queues(instance_number, queues): + return [ + dict( + name=q.name, + count=q.count, + queued_url=url_for( + ".jobs_overview", + instance_number=instance_number, + queue_name=q.name, + registry_name="queued", + per_page="8", + page="1", + ), + failed_job_registry_count=FailedJobRegistry(q.name).count, + failed_url=url_for( + ".jobs_overview", + instance_number=instance_number, + queue_name=q.name, + registry_name="failed", + per_page="8", + page="1", + ), + started_job_registry_count=StartedJobRegistry(q.name).count, + started_url=url_for( + ".jobs_overview", + instance_number=instance_number, + queue_name=q.name, + registry_name="started", + per_page="8", + page="1", + ), + deferred_job_registry_count=DeferredJobRegistry(q.name).count, + deferred_url=url_for( + ".jobs_overview", + instance_number=instance_number, + queue_name=q.name, + registry_name="deferred", + per_page="8", + page="1", + ), + finished_job_registry_count=FinishedJobRegistry(q.name).count, + finished_url=url_for( + ".jobs_overview", + instance_number=instance_number, + queue_name=q.name, + registry_name="finished", + per_page="8", + page="1", + ), + ) + for q in queues + ] + + +def serialize_date(dt): + if dt is None: + return None + return arrow.get(dt).to("UTC").datetime.isoformat() + + +def serialize_job(job): + return dict( + id=job.id, + created_at=serialize_date(job.created_at), + ended_at=serialize_date(job.ended_at), + exc_info=str(job.exc_info) if job.exc_info else None, + description=job.description, + ) + + +def serialize_current_job(job): + if job is None: + return "idle" + return dict( + job_id=job.id, + description=job.description, + created_at=serialize_date(job.created_at), + call_string=job.get_call_string(), + ) + + +def remove_none_values(input_dict): + return dict(((k, v) for k, v in input_dict.items() if v is not None)) + + +def pagination_window(total_items, cur_page, per_page, window_size=10): + all_pages = range(1, int(ceil(total_items / float(per_page))) + 1) + result = all_pages + if window_size >= 1: + temp = min( + len(all_pages) - window_size, (cur_page - 1) - int(ceil(window_size / 2.0)) + ) + pages_window_start = max(0, temp) + pages_window_end = pages_window_start + window_size + result = all_pages[pages_window_start:pages_window_end] + return result + + +def get_queue_registry_jobs_count(queue_name, registry_name, offset, per_page): + queue = Queue(queue_name) + if registry_name != "queued": + if per_page >= 0: + per_page = offset + (per_page - 1) + + if registry_name == "failed": + current_queue = FailedJobRegistry(queue_name) + elif registry_name == "deferred": + current_queue = DeferredJobRegistry(queue_name) + elif registry_name == "started": + current_queue = StartedJobRegistry(queue_name) + elif registry_name == "finished": + current_queue = FinishedJobRegistry(queue_name) + else: + current_queue = queue + total_items = current_queue.count + + job_ids = current_queue.get_job_ids(offset, per_page) + current_queue_jobs = [queue.fetch_job(job_id) for job_id in job_ids] + jobs = [serialize_job(job) for job in current_queue_jobs] + + return (total_items, jobs) + + +def escape_format_instance_list(url_list): + if isinstance(url_list, (list, tuple)): + url_list = [re.sub(r"://:[^@]*@", "://:***@", x) for x in url_list] + elif isinstance(url_list, string_types): + url_list = [re.sub(r"://:[^@]*@", "://:***@", url_list)] + return url_list + + +@blueprint.route("/", defaults={"instance_number": 0}) +@blueprint.route("//") +@blueprint.route("//view") +@blueprint.route("//view/queues") +def queues_overview(instance_number): + r = make_response( + render_template( + "settings/rq_dashboard/queues.html", + current_instance=instance_number, + instance_list=current_app.config.get("RQ_DASHBOARD_REDIS_URL"), + queues=Queue.all(), + rq_url_prefix=url_for(".queues_overview"), + rq_dashboard_version=rq_dashboard_version, + rq_version=rq_version, + active_tab="queues", + deprecation_options_usage=current_app.config.get( + "DEPRECATED_OPTIONS", False + ), + segment='settings-maintenance-redis-queues', + ) + ) + r.headers.set("Cache-Control", "no-store") + return r + + +@blueprint.route("//view/workers") +def workers_overview(instance_number): + r = make_response( + render_template( + "settings/rq_dashboard/workers.html", + current_instance=instance_number, + instance_list=current_app.config.get("RQ_DASHBOARD_REDIS_URL"), + workers=Worker.all(), + rq_url_prefix=url_for(".queues_overview"), + rq_dashboard_version=rq_dashboard_version, + rq_version=rq_version, + active_tab="workers", + deprecation_options_usage=current_app.config.get( + "DEPRECATED_OPTIONS", False + ), + segment='settings-maintenance-redis-workers', + ) + ) + r.headers.set("Cache-Control", "no-store") + return r + + +@blueprint.route( + "//view/jobs", + defaults={ + "queue_name": None, + "registry_name": "queued", + "per_page": "8", + "page": "1", + }, +) +@blueprint.route( + "//view/jobs////" +) +def jobs_overview(instance_number, queue_name, registry_name, per_page, page): + if queue_name is None: + queue = Queue() + else: + queue = Queue(queue_name) + r = make_response( + render_template( + "settings/rq_dashboard/jobs.html", + current_instance=instance_number, + instance_list=current_app.config.get("RQ_DASHBOARD_REDIS_URL"), + queues=Queue.all(), + queue=queue, + per_page=per_page, + page=page, + registry_name=registry_name, + rq_url_prefix=url_for(".queues_overview"), + rq_dashboard_version=rq_dashboard_version, + rq_version=rq_version, + active_tab="jobs", + deprecation_options_usage=current_app.config.get( + "DEPRECATED_OPTIONS", False + ), + segment='settings-maintenance-redis-jobs', + ) + ) + r.headers.set("Cache-Control", "no-store") + return r + + +@blueprint.route("//view/job/") +def job_view(instance_number, job_id): + job = Job.fetch(job_id) + r = make_response( + render_template( + "settings/rq_dashboard/job.html", + current_instance=instance_number, + instance_list=current_app.config.get("RQ_DASHBOARD_REDIS_URL"), + id=job.id, + rq_url_prefix=url_for(".queues_overview"), + rq_dashboard_version=rq_dashboard_version, + rq_version=rq_version, + deprecation_options_usage=current_app.config.get( + "DEPRECATED_OPTIONS", False + ), + segment='settings-maintenance-redis-job', + ) + ) + r.headers.set("Cache-Control", "no-store") + return r + + +@blueprint.route("/job//delete", methods=["POST"]) +@jsonify +def delete_job_view(job_id): + job = Job.fetch(job_id) + job.delete() + return dict(status="OK") + + +@blueprint.route("/job//requeue", methods=["POST"]) +@jsonify +def requeue_job_view(job_id): + requeue_job(job_id, connection=current_app.redis_conn) + return dict(status="OK") + + +@blueprint.route("/requeue/", methods=["GET", "POST"]) +@jsonify +def requeue_all(queue_name): + fq = Queue(queue_name).failed_job_registry + job_ids = fq.get_job_ids() + count = len(job_ids) + for job_id in job_ids: + requeue_job(job_id, connection=current_app.redis_conn) + return dict(status="OK", count=count) + + +@blueprint.route("/queue///empty", methods=["POST"]) +@jsonify +def empty_queue(queue_name, registry_name): + if registry_name == "queued": + q = Queue(queue_name) + q.empty() + elif registry_name == "failed": + ids = FailedJobRegistry(queue_name).get_job_ids() + for id in ids: + delete_job_view(id) + elif registry_name == "deferred": + ids = DeferredJobRegistry(queue_name).get_job_ids() + for id in ids: + delete_job_view(id) + elif registry_name == "started": + ids = StartedJobRegistry(queue_name).get_job_ids() + for id in ids: + delete_job_view(id) + elif registry_name == "finished": + ids = FinishedJobRegistry(queue_name).get_job_ids() + for id in ids: + delete_job_view(id) + return dict(status="OK") + + +@blueprint.route("/queue//compact", methods=["POST"]) +@jsonify +def compact_queue(queue_name): + q = Queue(queue_name) + q.compact() + return dict(status="OK") + + +@blueprint.route("//data/queues.json") +@jsonify +def list_queues(instance_number): + queues = serialize_queues(instance_number, sorted(Queue.all())) + return dict(queues=queues) + + +@blueprint.route( + "//data/jobs////.json" +) +@jsonify +def list_jobs(instance_number, queue_name, registry_name, per_page, page): + current_page = int(page) + per_page = int(per_page) + offset = (current_page - 1) * per_page + total_items, jobs = get_queue_registry_jobs_count( + queue_name, registry_name, offset, per_page + ) + + pages_numbers_in_window = pagination_window(total_items, current_page, per_page) + pages_in_window = [ + dict( + number=p, + url=url_for( + ".jobs_overview", + instance_number=instance_number, + queue_name=queue_name, + registry_name=registry_name, + per_page=per_page, + page=p, + ), + ) + for p in pages_numbers_in_window + ] + last_page = int(ceil(total_items / float(per_page))) + + prev_page = None + if current_page > 1: + prev_page = dict( + url=url_for( + ".jobs_overview", + instance_number=instance_number, + queue_name=queue_name, + registry_name=registry_name, + per_page=per_page, + page=(current_page - 1), + ) + ) + + next_page = None + if current_page < last_page: + next_page = dict( + url=url_for( + ".jobs_overview", + instance_number=instance_number, + queue_name=queue_name, + registry_name=registry_name, + per_page=per_page, + page=(current_page + 1), + ) + ) + + first_page_link = dict( + url=url_for( + ".jobs_overview", + instance_number=instance_number, + queue_name=queue_name, + registry_name=registry_name, + per_page=per_page, + page=1, + ) + ) + last_page_link = dict( + url=url_for( + ".jobs_overview", + instance_number=instance_number, + queue_name=queue_name, + registry_name=registry_name, + per_page=per_page, + page=last_page, + ) + ) + + pagination = remove_none_values( + dict( + current_page=current_page, + num_pages=last_page, + pages_in_window=pages_in_window, + next_page=next_page, + prev_page=prev_page, + first_page=first_page_link, + last_page=last_page_link, + ) + ) + + return dict( + name=queue_name, registry_name=registry_name, jobs=jobs, pagination=pagination + ) + + +@blueprint.route("//data/job/.json") +@jsonify +def job_info(instance_number, job_id): + job = Job.fetch(job_id) + return dict( + id=job.id, + created_at=serialize_date(job.created_at), + enqueued_at=serialize_date(job.enqueued_at), + ended_at=serialize_date(job.ended_at), + origin=job.origin, + status=job.get_status(), + result=job._result, + exc_info=str(job.exc_info) if job.exc_info else None, + description=job.description, + ) + + +@blueprint.route("//data/workers.json") +@jsonify +def list_workers(instance_number): + def serialize_queue_names(worker): + return [q.name for q in worker.queues] + + workers = sorted( + ( + dict( + name=worker.name, + queues=serialize_queue_names(worker), + state=str(worker.get_state()), + current_job=serialize_current_job(worker.get_current_job()), + version=getattr(worker, "version", ""), + python_version=getattr(worker, "python_version", ""), + ) + for worker in Worker.all() + ), + key=lambda w: (w["state"], w["queues"], w["name"]), + ) + return dict(workers=workers) + + +@blueprint.context_processor +def inject_interval(): + interval = current_app.config.get("RQ_DASHBOARD_POLL_INTERVAL", 2500) + return dict(poll_interval=interval) diff --git a/apps/alpr/routes/settings/maintenance/rq_dashboard/test.py b/apps/alpr/routes/settings/maintenance/rq_dashboard/test.py new file mode 100644 index 0000000..761855c --- /dev/null +++ b/apps/alpr/routes/settings/maintenance/rq_dashboard/test.py @@ -0,0 +1,9 @@ +from apps import default_q +from apps.alpr.routes.settings.maintenance.rq_dashboard.queue_functions import count_words_at_url + +if __name__ == "__main__": + for i in range(10): + job = default_q.enqueue(count_words_at_url, 'http://nvie.com') + print(job.result) + + diff --git a/apps/alpr/routes/settings/maintenance/rq_dashboard/version.py b/apps/alpr/routes/settings/maintenance/rq_dashboard/version.py new file mode 100644 index 0000000..5a2867d --- /dev/null +++ b/apps/alpr/routes/settings/maintenance/rq_dashboard/version.py @@ -0,0 +1 @@ +VERSION = "0.6.0" diff --git a/apps/alpr/routes/settings/notifications/__init__.py b/apps/alpr/routes/settings/notifications/__init__.py new file mode 100644 index 0000000..f7f05ce --- /dev/null +++ b/apps/alpr/routes/settings/notifications/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +blueprint = Blueprint( + 'notifications', + __name__, + url_prefix='/settings/notifications' +) diff --git a/apps/alpr/routes/settings/notifications/routes.py b/apps/alpr/routes/settings/notifications/routes.py new file mode 100644 index 0000000..dd3adee --- /dev/null +++ b/apps/alpr/routes/settings/notifications/routes.py @@ -0,0 +1,294 @@ +import logging + +from flask import render_template, request, jsonify +from flask_login import login_required, current_user + +from apps.alpr.models.settings import EmailNotificationSettings, TwilioNotificationSettings +from apps.alpr.notify import Email, SMS +from apps.alpr.routes.settings.notifications import blueprint +from apps.authentication.routes import ROLE_ADMIN +from apps.helpers import message +import apps.helpers as helper + + +@blueprint.route('/edit/smtp', methods=['PUT']) +@login_required +def edit_smtp(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + if request.method == 'PUT': + data = request.form + + # Validate before proceeding + hostname = data.get('smtp_hostname') + port = data.get('smtp_port') + username_email = data.get('smtp_username_email') + password = data.get('smtp_password') + recipients = data.get('smtp_recipients') + + # Check to if we can even put the values through the validators + if len(hostname) == 0: + return jsonify({'error': message['ip_hostname_not_valid']}), 404 + + is_valid_hostname = helper.is_valid_hostname(hostname) + is_valid_ip = helper.is_valid_ip(hostname) + is_valid_port = helper.is_valid_port(int(port)) + is_valid_email = helper.emailValidate(username_email) + are_valid_recipients = helper.are_valid_email_recipients(recipients) + + if not is_valid_hostname and not is_valid_ip: + return jsonify({'error': message['ip_hostname_not_valid']}), 404 + if not is_valid_port: + return jsonify({'error': message['port_not_valid']}), 404 + if not is_valid_email: + return jsonify({'error': message['not_valid_smtp_username_email']}), 404 + if not are_valid_recipients: + return jsonify({'error': message['not_valid_smtp_recipients']}), 404 + + # Lets find the settings in the db + email_notification_settings = EmailNotificationSettings.get_settings() + + if email_notification_settings: + try: + # Update it! + email_notification_settings.hostname = hostname + email_notification_settings.port = port + email_notification_settings.username_email = username_email + email_notification_settings.password = password + email_notification_settings.recipients = recipients + email_notification_settings.save() + except Exception as ex: + logging.exception(ex) + return jsonify({'error': message['could_not_process']}), 404 + return jsonify({'message': message['smtp_updated']}), 200 + else: + return jsonify({'error': message['smtp_settings_not_found']}), 404 + + +@blueprint.route('/enable/smtp', methods=['PUT']) +@login_required +def enable_smtp(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + if request.method == 'PUT': + # Lets find the settings in the db + email_notification_settings = EmailNotificationSettings.get_settings() + + if email_notification_settings: + email_notification_settings.enabled = True + try: + email_notification_settings.save() + except Exception as ex: + logging.exception(ex) + return jsonify({'error': message['could_not_process']}), 404 + return jsonify({'message': message['smtp_updated']}), 200 + else: + return jsonify({'error': message['smtp_settings_not_found']}), 404 + + +@blueprint.route('/disable/smtp', methods=['PUT']) +@login_required +def disable_smtp(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + if request.method == 'PUT': + # Lets find the settings in the db + email_notification_settings = EmailNotificationSettings.get_settings() + + if email_notification_settings: + email_notification_settings.enabled = False + try: + email_notification_settings.save() + except Exception as ex: + logging.exception(ex) + return jsonify({'error': message['could_not_process']}), 404 + return jsonify({'message': message['smtp_updated']}), 200 + else: + return jsonify({'error': message['smtp_settings_not_found']}), 404 + + +@blueprint.route('/test/smtp', methods=['PUT']) +@login_required +def test_smtp(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + if request.method == 'PUT': + # Lets find the settings in the db + email_notification_settings = EmailNotificationSettings.get_settings() + + if email_notification_settings: + # Validate before proceeding + hostname = email_notification_settings.hostname + port = email_notification_settings.port + username_email = email_notification_settings.username_email + password = email_notification_settings.password + recipients = email_notification_settings.recipients + + # Check to if we can even put the values through the validators + if len(hostname) == 0: + return jsonify({'error': message['ip_hostname_not_valid']}), 404 + + is_valid_hostname = helper.is_valid_hostname(hostname) + is_valid_ip = helper.is_valid_ip(hostname) + is_valid_port = helper.is_valid_port(port) + is_valid_email = helper.emailValidate(username_email) + are_valid_recipients = helper.are_valid_email_recipients(recipients) + + if not is_valid_hostname and not is_valid_ip: + return jsonify({'error': message['ip_hostname_not_valid']}), 404 + if not is_valid_port: + return jsonify({'error': message['port_not_valid']}), 404 + if not is_valid_email: + return jsonify({'error': message['not_valid_smtp_username_email']}), 404 + if not are_valid_recipients: + return jsonify({'error': message['not_valid_smtp_recipients']}), 404 + + try: + email = Email() + email.send_test() + except Exception as ex: + logging.exception(ex) + # [WinError 10061] No connection could be made because the target machine actively refused it -> + # ['[WinError 10061', ' No connection could be made because the target machine actively refused it'] + ex = str(ex).split(']') + return jsonify({'error': message['smtp_test_unsuccessful'] + ex[1]}), 404 + # Success! + return jsonify({'message': message['smtp_test_successful']}), 200 + else: + return jsonify({'error': message['smtp_settings_not_found']}), 404 + + +@blueprint.route('/enable/sms', methods=['PUT']) +@login_required +def enable_sms(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + if request.method == 'PUT': + # Lets find the settings in the db + sms_notification_settings = TwilioNotificationSettings.get_settings() + + if sms_notification_settings: + sms_notification_settings.enabled = True + try: + sms_notification_settings.save() + except Exception as ex: + logging.exception(ex) + return jsonify({'error': message['could_not_process']}), 404 + return jsonify({'message': message['sms_updated']}), 200 + else: + return jsonify({'error': message['sms_settings_not_found']}), 404 + + +@blueprint.route('/disable/sms', methods=['PUT']) +@login_required +def disable_sms(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + if request.method == 'PUT': + # Lets find the settings in the db + sms_notification_settings = TwilioNotificationSettings.get_settings() + + if sms_notification_settings: + sms_notification_settings.enabled = False + try: + sms_notification_settings.save() + except Exception as ex: + logging.exception(ex) + return jsonify({'error': message['could_not_process']}), 404 + return jsonify({'message': message['sms_updated']}), 200 + else: + return jsonify({'error': message['sms_settings_not_found']}), 404 + + +@blueprint.route('/edit/sms', methods=['PUT']) +@login_required +def edit_sms(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + if request.method == 'PUT': + data = request.form + + # Validate before proceeding + account_sid = data.get('sms_account_sid') + auth_token = data.get('sms_auth_token') + phone_number = data.get('sms_phone_number') + recipients = data.get('sms_recipients') + + is_valid_phone_number = helper.are_valid_sms_recipients(phone_number) + are_valid_recipients = helper.are_valid_sms_recipients(recipients) + + if len(account_sid) != 34: + return jsonify({'error': message['sms_invalid_account_sid']}), 404 + if len(auth_token) != 32: + return jsonify({'error': message['sms_invalid_auth_token']}), 404 + if not is_valid_phone_number: + return jsonify({'error': message['sms_invalid_phone_number']}), 404 + if not are_valid_recipients: + return jsonify({'error': message['sms_invalid_recipients']}), 404 + + # Let's find the settings in the db + sms_notification_settings = TwilioNotificationSettings.get_settings() + + if sms_notification_settings: + try: + # Update it! + sms_notification_settings.account_sid = account_sid + sms_notification_settings.auth_token = auth_token + sms_notification_settings.phone_number = phone_number + sms_notification_settings.recipients = recipients + sms_notification_settings.save() + except Exception as ex: + logging.exception(ex) + return jsonify({'error': message['could_not_process']}), 404 + return jsonify({'message': message['sms_updated']}), 200 + else: + return jsonify({'error': message['sms_settings_not_found']}), 404 + + +@blueprint.route('/test/sms', methods=['PUT']) +@login_required +def test_sms(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + if request.method == 'PUT': + # Lets find the settings in the db + sms_notification_settings = TwilioNotificationSettings.get_settings() + + if sms_notification_settings: + # Validate before proceeding + account_sid = sms_notification_settings.account_sid + auth_token = sms_notification_settings.auth_token + phone_number = sms_notification_settings.phone_number + recipients = sms_notification_settings.recipients + + is_valid_phone_number = helper.are_valid_sms_recipients(phone_number) + are_valid_recipients = helper.are_valid_sms_recipients(recipients) + + if len(account_sid) != 34: + return jsonify({'error': message['sms_invalid_account_sid']}), 404 + if len(auth_token) != 32: + return jsonify({'error': message['sms_invalid_auth_token']}), 404 + if not is_valid_phone_number: + return jsonify({'error': message['sms_invalid_phone_number']}), 404 + if not are_valid_recipients: + return jsonify({'error': message['sms_invalid_recipients']}), 404 + + try: + sms = SMS() + sms.send_test() + except Exception as ex: + logging.exception(ex) + return jsonify({'error': message['sms_test_unsuccessful']}), 404 + + # Success! + return jsonify({'message': message['sms_test_successful']}), 200 + else: + return jsonify({'error': message['sms_settings_not_found']}), 404 diff --git a/apps/alpr/routes/settings/profile/__init__.py b/apps/alpr/routes/settings/profile/__init__.py new file mode 100644 index 0000000..fbac393 --- /dev/null +++ b/apps/alpr/routes/settings/profile/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +blueprint = Blueprint( + 'profile', + __name__, + url_prefix='/settings/profile' +) diff --git a/apps/alpr/routes/settings/profile/routes.py b/apps/alpr/routes/settings/profile/routes.py new file mode 100644 index 0000000..8dd5784 --- /dev/null +++ b/apps/alpr/routes/settings/profile/routes.py @@ -0,0 +1,138 @@ +import base64 +import logging +import os + +from flask import request, jsonify +from flask_login import login_required, current_user +from werkzeug.utils import secure_filename + +from apps.alpr.routes.settings.profile import blueprint +from apps.authentication.models import User, UserProfile +from apps.authentication.routes import upload_folder_name, ROLE_ADMIN, ROLE_USER, STATUS_ACTIVE, STATUS_SUSPENDED +from apps.authentication.util import hash_pass +from apps.helpers import message, unique_file_name, password_validate, are_valid_sms_recipients + + +@blueprint.route('/edit', methods=['GET', 'PUT']) +@login_required +def edit(): + """ + 1.Get User by id(Get user view) + 2.Update user(update user view) + Returns: + _type_: json data + """ + if request.method == 'GET': + + profile = UserProfile.find_by_id(request.args.get('user_id')) + user = User.find_by_id(request.args.get('user_id')) + + # if check user none or not + if profile and user: + + context = {'id': profile.id, 'full_name': profile.full_name, 'bio': profile.bio, + 'address': profile.address, 'zipcode': profile.zipcode, 'phone': profile.phone, + 'email': profile.email, 'website': profile.website, 'image': profile.image, + 'user_id': profile.user_id.id, 'api_key': str(user.api_token).upper(), 'status': user.status, + 'administrator': user.role, 'timezone': profile.timezone} + + return jsonify(context), 200 + + else: + return jsonify({'error': message['record_not_found']}), 404 + + if request.method == 'PUT': + data = request.form + image = request.files.get('image') + + profile = UserProfile.find_by_id(data.get('user_id')) + user = User.find_by_id(data.get('user_id')) + + if profile is not None and user is not None: + # Form data checks + if not are_valid_sms_recipients(data.get('phone')): + return jsonify({'error': message['invalid_phone_number']}), 404 + + # Change avatar + if image: + filename = unique_file_name(secure_filename(image.filename)) + full_file_path = os.path.join(upload_folder_name, filename) + try: + image.save(full_file_path) + with open(full_file_path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()) + profile.image = encoded_string.decode("utf-8") + user.avatar = encoded_string.decode("utf-8") + if os.path.isfile(full_file_path): + os.remove(full_file_path) + except Exception as ex: + if os.path.isfile(full_file_path): + os.remove(full_file_path) + logging.exception(ex) + return jsonify({'error': message['error_updating_user_profile']}), 500 + + if data.get('email') != '': + try: + profile.full_name = data.get('full_name') + profile.bio = data.get('bio') + profile.address = data.get('address') + profile.zipcode = data.get('zipcode') + profile.phone = data.get('phone') + profile.email = data.get('email') + profile.website = data.get('website') + profile.timezone = data.get('timezone') + + profile.save() + user.save() + except: + return jsonify({'error': "Email already exists."}), 404 + + profile = User.find_by_id(data.get('user_id')) + profile.email = data.get('email') + profile.save() + else: + profile.full_name = data.get('full_name') + profile.bio = data.get('bio') + profile.address = data.get('address') + profile.zipcode = data.get('zipcode') + profile.phone = data.get('phone') + profile.website = data.get('website') + profile.timezone = data.get('timezone') + + profile.save() + user.save() + + aMsg = message['user_updated_successfully'] + + return jsonify({'message': aMsg}), 200 + + else: + return jsonify({'error': message['record_not_found']}), 404 + + +@blueprint.route('/update/password', methods=['POST']) +@login_required +def update_password(): + """Change an existing user's password.""" + data = request.form + new_password = data.get('new_password') + new_password2 = data.get('new_password2') + + user = User.find_by_username(current_user.username) + + if request.method == 'POST': + # password validate + valid_pwd = password_validate(new_password) + if valid_pwd != True: + return jsonify({'error': valid_pwd}), 404 + + # check password match or not + if new_password != new_password2: + return jsonify({'error': message['pwd_not_match']}), 404 + + # if check old password none + else: + user.password = hash_pass(new_password) + user.save() + + return jsonify({'message': message['password_has_been_updated']}), 200 diff --git a/apps/alpr/routes/settings/routes.py b/apps/alpr/routes/settings/routes.py new file mode 100644 index 0000000..8c41138 --- /dev/null +++ b/apps/alpr/routes/settings/routes.py @@ -0,0 +1,130 @@ +import pytz +from flask import render_template, request, redirect, url_for +from flask_login import login_required, current_user +from flask_paginate import get_page_parameter, Pagination + +from apps import IPBanConfig, ip_ban_config +from apps.alpr.models.settings import EmailNotificationSettings, TwilioNotificationSettings, GeneralSettings, PostAuth +from apps.alpr.routes.settings import blueprint +from apps.alpr.routes.settings.cameras.manufacturers import get_camera_manufacturers +from apps.authentication.models import User, UserProfile +from apps.authentication.routes import ROLE_ADMIN + + +@blueprint.route('/agents', methods=["GET"]) +@login_required +def agents(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + return render_template('settings/agents.html', segment='settings-agents') + + +@blueprint.route('/cameras', methods=["GET"]) +@login_required +def cameras(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + return render_template('settings/cameras.html', segment='settings-camera', manufacturers=get_camera_manufacturers()) + + +@blueprint.route('/general', methods=["GET"]) +@login_required +def general(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + return render_template('settings/general.html', segment='settings-general', settings=GeneralSettings.get_settings(), + ipban=ip_ban_config.get_settings(), post_auth_levels=PostAuth) + + +@blueprint.route('/notifications', methods=["GET"]) +@login_required +def notifications(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + smtp_settings = EmailNotificationSettings.get_settings() + # Maybe it's a new instance of the app. + if smtp_settings is None: + smtp_settings = EmailNotificationSettings() + smtp_settings.save() + + sms_settings = TwilioNotificationSettings.get_settings() + # Maybe it's a new instance of the app. + if sms_settings is None: + sms_settings = TwilioNotificationSettings() + sms_settings.save() + + return render_template('settings/notifications.html', segment='settings-notifications', smtp=smtp_settings, + sms=sms_settings) + + +@blueprint.route('/profile', methods=['GET', 'PUT']) +@login_required +def profile(): + """ + Get user profile view + """ + if request.method == 'GET': + template = 'settings/profile.html' + + user = User.find_by_id(current_user.id) + user_profile = UserProfile.find_by_user_id(user.id) + + context = {'id': user.id, + 'profile_name': user_profile.full_name, + 'profile_bio': user_profile.bio, + 'profile_address': user_profile.address, + 'profile_zipcode': user_profile.zipcode, + 'profile_phone': user_profile.phone, + 'email': user_profile.email, + 'profile_website': user_profile.website, + 'profile_image': user_profile.image, + 'user_profile_id': user_profile.id, + 'api_token': user.api_token, + 'profile_timezone': user_profile.timezone + } + + return render_template(template, context=context, segment='settings-profile', timezones=pytz.common_timezones) + + return redirect(url_for('home_blueprint.index')) + + +@blueprint.route('/users', methods=['GET']) +@login_required +def users(): + if current_user.role != ROLE_ADMIN: + return redirect(url_for('home_blueprint.index')) + + if request.method == 'GET': + template = '/settings/users.html' + search = False + q = request.args.get('q') + if q: + search = True + + page = request.args.get(get_page_parameter(), type=int, default=1) + per_page = 10 + + # users records + users_obj = User + users = users_obj.query.paginate(page, per_page, error_out=True).items + + pagination = Pagination(page=page, per_page=per_page, + total=len(users_obj.query.all()), search=search, record_name='users') + + user_list = [] + if users is not None: + for user in users: + for data in UserProfile.query.filter_by(user=user.id): + user_list.append(data) + + return render_template(template, + users_data=user_list, + pagination=pagination, + segment='settings-users' + ) + + return redirect(url_for('home_blueprint.index')) \ No newline at end of file diff --git a/apps/alpr/routes/settings/users/__init__.py b/apps/alpr/routes/settings/users/__init__.py new file mode 100644 index 0000000..fe92514 --- /dev/null +++ b/apps/alpr/routes/settings/users/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +blueprint = Blueprint( + 'users', + __name__, + url_prefix='/settings/users' +) diff --git a/apps/alpr/routes/settings/users/routes.py b/apps/alpr/routes/settings/users/routes.py new file mode 100644 index 0000000..e9bdaa7 --- /dev/null +++ b/apps/alpr/routes/settings/users/routes.py @@ -0,0 +1,266 @@ +import logging + +from flask import request, jsonify, render_template +from flask_login import login_required, current_user +from password_generator import PasswordGenerator + +from apps import db, helpers +from apps.alpr.models.settings import EmailNotificationSettings +from apps.alpr.notify import Email +from apps.alpr.routes.settings.users import blueprint +from apps.authentication.models import User, UserProfile +from apps.authentication.routes import STATUS_ACTIVE, STATUS_SUSPENDED, ROLE_ADMIN, ROLE_USER +from apps.authentication.signals import user_saved_signals +from apps.authentication.util import hash_pass +from apps.helpers import message, password_validate, createAccessToken, get_ts + + +@blueprint.route('/check/smtp', methods=['POST']) +def check_smtp(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + settings = EmailNotificationSettings.get_settings() + + if settings is None: + return jsonify({'error': 'Settings have not been initialized'}), 404 + + # Validate SMTP settings + is_valid_hostname = helpers.is_valid_hostname(settings.hostname) + is_valid_ip = helpers.is_valid_ip(settings.hostname) + is_valid_port = helpers.is_valid_port(int(settings.port)) + is_valid_email = helpers.emailValidate(settings.username_email) + are_valid_recipients = helpers.are_valid_email_recipients(settings.recipients) + + if not is_valid_hostname and not is_valid_ip: + return jsonify({'error': message['ip_hostname_not_valid']}), 404 + if not is_valid_port: + return jsonify({'error': message['port_not_valid']}), 404 + if not is_valid_email: + return jsonify({'error': message['not_valid_smtp_username_email']}), 404 + if not are_valid_recipients: + return jsonify({'error': message['not_valid_smtp_recipients']}), 404 + + return jsonify({'message': ''}), 200 + + +@blueprint.route('/edit', methods=['PUT']) +@login_required +def edit(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + if request.method == 'PUT': + data = request.form + + profile = UserProfile.find_by_id(data.get('user_id')) + user = User.find_by_id(data.get('user_id')) + + if profile is not None and user is not None: + + # Check if we can modify admin status. + desired_admin_status = data.get('administrator') + if desired_admin_status == "on": + desired_admin_status = ROLE_ADMIN + elif desired_admin_status == None: + desired_admin_status = ROLE_USER + + # Cannot demote super admin! + if user.id == 1 and user.role != desired_admin_status: + return jsonify({'error': message['access_denied']}), 404 + + # Otherwise, change it! + if user.role != desired_admin_status: + if user.role == ROLE_ADMIN: + user.role = ROLE_USER + else: + user.role = ROLE_ADMIN + # if check login failed + if user.failed_logins > 0: + user.failed_logins = 0 + + # Check if we can suspend account + desired_account_status = data.get('status') + if desired_account_status == "on": + desired_account_status = STATUS_ACTIVE + elif desired_account_status == None: + desired_account_status = STATUS_SUSPENDED + + # Cannot suspend super admin! + if user.id == 1 and user.status != desired_account_status: + return jsonify({'error': message['access_denied']}), 404 + + if user.status != desired_account_status: + if user.status == STATUS_ACTIVE: + user.status = STATUS_SUSPENDED + else: + user.status = STATUS_ACTIVE + # if check login failed + if user.failed_logins > 0: + user.failed_logins = 0 + + if data.get('email') != '': + try: + profile.full_name = data.get('full_name') + profile.bio = data.get('bio') + profile.address = data.get('address') + profile.zipcode = data.get('zipcode') + profile.phone = data.get('phone') + profile.email = data.get('email') + profile.website = data.get('website') + + profile.save() + user.save() + except: + return jsonify({'error': "Email already exists."}), 404 + + profile = User.find_by_id(data.get('user_id')) + profile.email = data.get('email') + profile.save() + else: + profile.full_name = data.get('full_name') + profile.bio = data.get('bio') + profile.address = data.get('address') + profile.zipcode = data.get('zipcode') + profile.phone = data.get('phone') + profile.website = data.get('website') + + profile.save() + user.save() + + aMsg = message['user_updated_successfully'] + + return jsonify({'message': aMsg}), 200 + + else: + return jsonify({'error': message['record_not_found']}), 404 + + +@blueprint.route('/register', methods=['POST']) +def register(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + data = request.form + username = data.get('username') + email = data.get('email') + password = data.get('password') + confirm_password = data.get('confirm_password') + + if password != confirm_password: + return jsonify({'error': message['pwd_not_match']}), 404 + + # Check if username exists + user = User.find_by_username(username) + if user: + return jsonify({'error': message['username_already_registered']}), 404 + + # Check if email exists + user = User.find_by_email(email) + if user: + return jsonify({'error': message['email_already_registered']}), 404 + + valid_pwd = password_validate(password) + if not valid_pwd: + return jsonify({'error': valid_pwd}), 404 + + user = User(**request.form) + user.api_token = createAccessToken() + user.api_token_ts = get_ts() + user.save() + + # send signal for create profile + user_saved_signals.send({"user_id": user.id, "email": user.email}) + + return jsonify({'message': message['account_created_successfully']}), 200 + + +@blueprint.route('/reset/password', methods=['POST']) +@login_required +def reset_password(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + data = request.form + + user_id = int(data.get('user_id')) + user = User.find_by_id(user_id) + + if user is None: + return jsonify({'error': 'User not found!'}), 404 + + try: + pg = PasswordGenerator() + pg.minlen = 16 + pg.maxlen = 16 + pg.minuchars = 4 + pg.minlchars = 3 + pg.minnumbers = 4 + pg.minschars = 4 + password = pg.generate() + user.password = hash_pass(password) + email = Email() + email.tag = "Account" + email.subject = "Account Password Reset" + email.body = "Hello {},\nYour password has been reset.\nYour new password is: {}".format(user.username, + password) + email.recipients = [user.email] + email.send() + # Don't save the new user password unless an email was sent without an exception + user.save() + except Exception as ex: + logging.exception(ex) + return jsonify({'error': 'Something went wrong! Please make sure email SMTP settings are correct.'}), 404 + + return jsonify({'message': 'Password has been reset! User has been emailed with a new password.'}), 200 + + +@blueprint.route("/search", methods=['GET']) +@login_required +def search(): + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + if request.method == 'GET': + query = User.query + + search = request.args.get('search') + if search: + query = query.filter(db.or_(User.username.like(f'%{search}%'), User.email.like(f'%{search}%'), + User.api_token.like(f'%{search}%'))) + total = query.count() + + # pagination + start = request.args.get('start', type=int, default=-1) + length = request.args.get('length', type=int, default=-1) + if start != -1 and length != -1: + query = query.offset(start).limit(length) + + dt = helpers.Timezone(current_user) + users_list = [] + for user in query: + for profile in UserProfile.query.filter_by(user=user.id): + users_data = { + 'id': user.id, + 'avatar': profile.image, + 'username': user.username, + 'email': user.email, + 'role': user.role, + 'status': user.status, + 'date_created': dt.astimezone(user.date_created), + 'full_name': profile.full_name, + 'bio': profile.bio, + 'address': profile.address, + 'zipcode': profile.zipcode, + 'phone': profile.phone, + 'website': profile.website, + 'image': profile.image, + 'profile_id': profile.id, + 'api_token': str(user.api_token[-4:]).upper() + } + users_list.append(users_data) + + return { + 'data': users_list, + 'total': total, + } diff --git a/apps/alpr/util.py b/apps/alpr/util.py new file mode 100644 index 0000000..a5bcfbf --- /dev/null +++ b/apps/alpr/util.py @@ -0,0 +1,10 @@ +import json + + +def response(Response, msg, status=200): + return Response(json.dumps({'status': status, 'msg': msg}), status=status, mimetype="application/json") + + +def save_to_json(json_obj_collection_all, filename): + with open('apps/log/{}.json'.format(filename), 'w', encoding='utf-8') as f: + json.dump(json_obj_collection_all, f, ensure_ascii=False, indent=4) diff --git a/apps/api/__init__.py b/apps/api/__init__.py new file mode 100644 index 0000000..b498da8 --- /dev/null +++ b/apps/api/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +blueprint = Blueprint( + 'api_blueprint', + __name__, + url_prefix='/api' +) diff --git a/apps/api/controller/base_controller.py b/apps/api/controller/base_controller.py new file mode 100644 index 0000000..8660f06 --- /dev/null +++ b/apps/api/controller/base_controller.py @@ -0,0 +1,83 @@ +import flask +import traceback +from flask import jsonify +from apps.api.exception import InvalidUsage + +app = flask.current_app + + +class BaseController: + + # Set Json response + def send_response(self, data, status_code=200): + res = { + "data": data, + 'status': status_code + } + resp = jsonify(res) + return resp + + # Set success response. + def success(self, data, message="", code=200): + """form final respose format + + Args: + data (dict/list): for single record it would be dictionary and for multiple records it would be list. + message (str): operation response message. Defaults to "". + code (int): Http response code. Defaults to 200. + + Returns: + dictionary of response data + { + data: [], + message: "", + status: 200, + } + """ + res = { + "data": data, + "message": message, + 'status': code + } + resp = jsonify(res) + return resp + + # Set error response. + def error(self, e, code=422): + msg = str(e) + # if isinstance(e, InvalidUsage): + # msg = e.message + + # app.logger.error(traceback.format_exc()) + res = { + # 'error':{ + # 'description': traceback.format_exc() + # }, + "message": msg, + 'status': code + } + return res, code + + def errorGeneral(self, e, code=422): + msg = str(e) + # if isinstance(e, InvalidUsage): + # msg = e.message + res = { + "message": msg, + 'status': code + } + return res, code + # msg = 'An exception occurred: {}'.format(error) + # res = { + # 'errors':msg, + # 'status': code + # } + # return res + + def simple_response(self, message, status_code): + res = { + "message": message, + 'status': status_code + } + resp = jsonify(res) + return resp diff --git a/apps/api/controller/webhook_controller.py b/apps/api/controller/webhook_controller.py new file mode 100644 index 0000000..03ae3c2 --- /dev/null +++ b/apps/api/controller/webhook_controller.py @@ -0,0 +1,132 @@ +import json +import logging + +from flask import request +from flask_restx import Resource + +from apps.alpr.enums import DataType +from apps.alpr.models.settings import GeneralSettings, PostAuth +from apps.authentication.models import User, RoleType +from apps.messages import Messages + +from apps.api.controller.base_controller import BaseController + +from apps.api.schemas.alpr_alert_schema import ALPRAlertSchema +from apps.api.schemas.alpr_group_schema import ALPRGroupSchema +from apps.api.schemas.vehicle_schema import VehicleSchema + +from apps.api.service.alpr_alert_service import ALPRAlertService +from apps.api.service.alpr_group_service import ALPRGroupService +from apps.api.service.cache_service import CacheService +from apps.api.service.vehicle_service import VehicleService + +message = Messages.message + +# Base controller +BaseController = BaseController() + +# Schemas +alpr_alert_schema = ALPRAlertSchema() +alpr_alerts_schema = ALPRAlertSchema(many=True) +alpr_group_schema = ALPRGroupSchema() +alpr_groups_schema = ALPRGroupSchema(many=True) +vehicle_schema = VehicleSchema() +vehicles_schema = VehicleSchema(many=True) + +# Services +alpr_alert_service = ALPRAlertService() +alpr_group_service = ALPRGroupService() +vehicle_service = VehicleService() + + +class Webhook(Resource): + def post(self): + # Add security check. We do not want any unauthorized POSTing of data + settings = GeneralSettings.get_settings() + auth_level = settings.post_auth + + if request.is_json: + if auth_level == PostAuth.DISABLE_POSTING: + return BaseController.errorGeneral("POST has been disabled"), 403 + elif auth_level == PostAuth.USERS_ADMINS or auth_level == PostAuth.ADMINS_ONLY: + try: + # Re-serialize the object "custom_data": "{\"API_KEY\": \"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\"}" -> + # "custom_data": {"API_KEY": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"} + json_obj = json.loads(request.json['custom_data']) + + api_key = json_obj['API_KEY'] + if api_key != "": + token_holder = User.find_by_api_token(api_key) + if token_holder is None: + return BaseController.errorGeneral("Unknown API_KEY holder"), 403 + else: + return BaseController.errorGeneral("Missing API_KEY in custom_data"), 403 + + if auth_level == PostAuth.ADMINS_ONLY: + if token_holder.role != RoleType['ADMIN']: + return BaseController.errorGeneral("API_KEY does not belong to an administrator"), 403 + + except KeyError as ex: + logging.error("Could not process custom_data") + return BaseController.errorGeneral("Could not process custom_data") + + # Redefine custom_data for the schema validators + request.json['custom_data'] = json_obj + + # Enumerate the 'data_type' key + data_type = DataType(request.json['data_type']) + + if data_type == DataType.GROUP: + try: + request_data = request.json + # Make sure incoming data conforms + validated = alpr_group_schema.load(request_data) + # Insert the record into the database and save it + record = alpr_group_service.create(validated) + # Return the data inserted back to client as an ack + data = alpr_group_schema.dump(record) + # Update cache + cache_service = CacheService(request_data, record.id) + cache_service.update() + return BaseController.success(data, message['record_created_successfully']) + except Exception as e: + logging.exception(e) + return BaseController.error(e, 422) + elif data_type == DataType.ALERT: + try: + request_data = request.json + # Make sure incoming data conforms + validated = alpr_alert_schema.load(request_data) + # Insert the record into the database and save it + record = alpr_alert_service.create(validated) + # Return the data inserted back to client as an ack + data = alpr_alert_schema.dump(record) + # Update cache + cache_service = CacheService(request_data, record.id) + cache_service.update() + return BaseController.success(data, message['record_created_successfully']) + except Exception as e: + logging.exception(e) + return BaseController.error(e, 422) + elif data_type == DataType.VEHICLE: + try: + request_data = request.json + # Make sure incoming data conforms + validated = vehicle_schema.load(request_data) + # Insert the record into the database and save it + record = vehicle_service.create(validated) + # Return the data inserted back to client as an ack + data = vehicle_schema.dump(record) + # Update cache + cache_service = CacheService(request_data, record.id) + cache_service.update() + return BaseController.success(data, message['record_created_successfully']) + except Exception as e: + logging.exception(e) + return BaseController.error(e, 422) + else: + logging.error("data_type = {} is not valid".format(request.json['data_type'])) + return BaseController.errorGeneral("data_type not valid") + else: + logging.exception("Content type is not application/json?") + return BaseController.errorGeneral("Content type is not application/json?") diff --git a/apps/api/exception.py b/apps/api/exception.py new file mode 100644 index 0000000..6e3ab02 --- /dev/null +++ b/apps/api/exception.py @@ -0,0 +1,15 @@ +class InvalidUsage(Exception): + status_code = 400 + + def __init__(self, message, status_code=None, payload=None): + Exception.__init__(self) + self.message = message + if status_code is not None: + self.status_code = status_code + self.payload = payload + + def to_dict(self): + rv = dict(self.payload or ()) + rv['message'] = self.message + + return rv diff --git a/apps/api/routes.py b/apps/api/routes.py new file mode 100644 index 0000000..65f68f9 --- /dev/null +++ b/apps/api/routes.py @@ -0,0 +1,12 @@ +from apps.api import blueprint +from apps.api.controller.webhook_controller import Webhook +from flask_restx import Api + + +# from flask_restx import Api +api = Api(blueprint, title="OpenALPR-Webhook", description="OpenALPR-Webhook is a self-hosted web application" + " that accepts Rekor Scout™ POST data allowing longer " + "data retention.") + +# ALPR Group/Alert & Vehicle POST end point. +api.add_resource(Webhook, '/webhook') diff --git a/apps/api/schemas/alpr_alert_schema.py b/apps/api/schemas/alpr_alert_schema.py new file mode 100644 index 0000000..d30ac81 --- /dev/null +++ b/apps/api/schemas/alpr_alert_schema.py @@ -0,0 +1,30 @@ +from ...alpr.models.alpr_alert import ALPRAlert +from marshmallow import fields, EXCLUDE +from marshmallow_sqlalchemy import ModelSchema +from apps import db + +# Defaults & missing +default_missing_custom_data = {"API_KEY": ""} + + +class ALPRAlertSchema(ModelSchema): + class Meta(ModelSchema.Meta): + fields = ("id", "data_type", "version", "epoch_time", "agent_uid", "alert_list", "alert_list_id", "site_name", + "camera_name", "camera_number", "plate_number", "description", "list_type", "group", "custom_data") + model = ALPRAlert + unknown = EXCLUDE + sqla_session = db.session + data_type = fields.String(default=None, missing=None) + version = fields.Integer(default=None, missing=None) + epoch_time = fields.Integer(default=None, missing=None) + agent_uid = fields.String(default=None, missing=None) + alert_list = fields.String(default=None, missing=None) + alert_list_id = fields.Integer(default=None, missing=None) + site_name = fields.String(default=None, missing=None) + camera_name = fields.String(default=None, missing=None) + camera_number = fields.Integer(default=None, missing=None) + plate_number = fields.String(default=None, missing=None) + description = fields.String(default=None, missing=None) + list_type = fields.String(default=None, missing=None) + group = fields.Dict(default=None, missing=None) + custom_data = fields.Dict(default=default_missing_custom_data, missing=default_missing_custom_data) diff --git a/apps/api/schemas/alpr_group_schema.py b/apps/api/schemas/alpr_group_schema.py new file mode 100644 index 0000000..3fea640 --- /dev/null +++ b/apps/api/schemas/alpr_group_schema.py @@ -0,0 +1,137 @@ +from ...alpr.models.alpr_group import ALPRGroup +from marshmallow import fields, EXCLUDE +from marshmallow_sqlalchemy import ModelSchema +from apps import db + +# Defaults & missing +from ...authentication.models import User + +default_missing_vehicle_path = [{"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}] + +default_missing_plate_indexes = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + +default_missing_candidates = [{"plate": "", "confidence": 0, "matches_template": 0}] + +default_missing_best_plate = {"plate": "", "confidence": 0, "matches_template": 0, "plate_index": 0, "region": "", + "region_confidence": 0, "processing_time_ms": 0, "requested_topn": 0, + "coordinates": [{"x": 0, "y": 0}, {"x": 0, "y": 0}, {"x": 0, "y": 0}, {"x": 0, "y": 0}], + "plate_crop_jpeg": "", "vehicle_region": {"x": 0, "y": 0, "width": 0, "height": 0}, + "vehicle_detected": 0, + "candidates": [{"plate": "", "confidence": 0, "matches_template": 0}]} + +default_missing_plate_path = [{"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}, + {"x": 0, "y": 0, "w": 0, "h": 0, "t": 0, "f": 0}] + +default_missing_vehicle = { + "color": [{"name": "", "confidence": 0}, {"name": "", "confidence": 0}, {"name": "", "confidence": 0}, + {"name": "", "confidence": 0}, {"name": "", "confidence": 0}], + "make": [{"name": "", "confidence": 0}, {"name": "", "confidence": 0}, {"name": "", "confidence": 0}, + {"name": "", "confidence": 0}, {"name": "", "confidence": 0}], + "make_model": [{"name": "", "confidence": 0}, {"name": "", "confidence": 0}, {"name": "", "confidence": 0}, + {"name": "", "confidence": 0}, {"name": "", "confidence": 0}], + "body_type": [{"name": "", "confidence": 0}, {"name": "", "confidence": 0}, {"name": "", "confidence": 0}, + {"name": "", "confidence": 0}, {"name": "", "confidence": 0}], + "year": [{"name": "", "confidence": 0}, {"name": "", "confidence": 0}, {"name": "", "confidence": 0}, + {"name": "", "confidence": 0}, {"name": "", "confidence": 0}], + "orientation": [{"name": "", "confidence": 0}, {"name": "", "confidence": 0}, {"name": "", "confidence": 0}, + {"name": "", "confidence": 0}, {"name": "", "confidence": 0}], + "missing_plate": [{"name": "", "confidence": 0}, {"name": "", "confidence": 0}], + "is_vehicle": [{"name": "", "confidence": 0}, {"name": "", "confidence": 0}]} + +default_missing_web_server_config = {"camera_label": "", "agent_label": ""} + +default_missing_custom_data = {"API_KEY": ""} + + +class ALPRGroupSchema(ModelSchema): + class Meta(ModelSchema.Meta): + fields = ( + "id", "data_type", "version", "epoch_start", "epoch_end", "frame_start", "frame_end", "company_id", + "agent_uid", "agent_version", "agent_type", "camera_id", "gps_latitude", "gps_longitude", "country", + "uuids", "vehicle_path", "plate_indexes", "candidates", "best_plate", "best_confidence", + "best_plate_number", "best_region", "best_region_confidence", "matches_template", "plate_path", + "vehicle_crop_jpeg", "overview_jpeg", "best_uuid", "best_uuid_epoch_ms", "best_image_width", + "best_image_height", "travel_direction", "is_parked", "is_preview", "vehicle_signature", "vehicle", + "web_server_config", "direction_of_travel_id", "custom_data") + model = ALPRGroup + unknown = EXCLUDE + sqla_session = db.session + + data_type = fields.String(default=None, missing=None) + version = fields.Integer(default=None, missing=None) + epoch_start = fields.Integer(default=None, missing=None) + epoch_end = fields.Integer(default=None, missing=None) + frame_start = fields.Integer(default=None, missing=None) + frame_end = fields.Integer(default=None, missing=None) + company_id = fields.String(default=None, missing=None) + agent_uid = fields.String(default=None, missing=None) + agent_version = fields.String(default=None, missing=None) + agent_type = fields.String(default=None, missing=None) + camera_id = fields.Integer(default=None, missing=None) + gps_latitude = fields.Float(default=None, missing=None) + gps_longitude = fields.Float(default=None, missing=None) + country = fields.String(default=None, missing=None) + uuids = fields.List(fields.String(), default=None, missing=None) + vehicle_path = fields.List(fields.Dict(), default=default_missing_vehicle_path, + missing=default_missing_vehicle_path) + plate_indexes = fields.List(fields.Integer(), default=default_missing_plate_indexes, + missing=default_missing_plate_indexes) + candidates = fields.List(fields.Dict(), default=default_missing_candidates, missing=default_missing_candidates) + best_plate = fields.Dict(default=default_missing_best_plate, missing=default_missing_best_plate) + best_confidence = fields.Float(default=None, missing=None) + best_plate_number = fields.String(default=None, missing=None) + best_region = fields.String(default=None, missing=None) + best_region_confidence = fields.Float(default=None, missing=None) + matches_template = fields.Boolean(default=None, missing=None) + plate_path = fields.List(fields.Dict(), default=default_missing_plate_path, missing=default_missing_plate_path) + vehicle_crop_jpeg = fields.String(default=None, missing=None) + overview_jpeg = fields.String(default=None, missing=None), + best_uuid = fields.String(default=None, missing=None) + best_uuid_epoch_ms = fields.Integer(default=None, missing=None) + best_image_width = fields.Integer(default=None, missing=None) + best_image_height = fields.Integer(default=None, missing=None) + travel_direction = fields.Float(default=None, missing=None) + is_parked = fields.Boolean(default=None, missing=None) + is_preview = fields.Boolean(default=None, missing=None) + vehicle_signature = fields.String(default=None, missing=None) + vehicle = fields.Dict(default=default_missing_vehicle, missing=default_missing_vehicle) + web_server_config = fields.Dict(default=default_missing_web_server_config, + missing=default_missing_web_server_config) + direction_of_travel_id = fields.Integer(default=None, missing=None) + custom_data = fields.Dict(default=default_missing_custom_data, missing=default_missing_custom_data) diff --git a/apps/api/schemas/custom_alert.py b/apps/api/schemas/custom_alert.py new file mode 100644 index 0000000..4e3696e --- /dev/null +++ b/apps/api/schemas/custom_alert.py @@ -0,0 +1,19 @@ +from ...alpr.models.custom_alert import CustomAlert +from marshmallow import fields, EXCLUDE +from marshmallow_sqlalchemy import ModelSchema +from apps import db + + +class CustomAlertSchema(ModelSchema): + class Meta(ModelSchema.Meta): + fields = ("id", "alpr_group_id", "license_plate", "region_match", "description", "notify_user_ids", + "submitted_by_user_id") + model = CustomAlert + unknown = EXCLUDE + sqla_session = db.session + alpr_group_id = fields.Integer(default=None, missing=None) + license_plate = fields.String(default=None, missing=None) + region_match = fields.Boolean(default=None, missing=None) + description = fields.String(default=None, missing=None) + notify_user_ids = fields.Dict(default=None, missing=None) + submitted_by_user_id = fields.Integer(default=None, missing=None) diff --git a/apps/api/schemas/vehicle_schema.py b/apps/api/schemas/vehicle_schema.py new file mode 100644 index 0000000..6b619bf --- /dev/null +++ b/apps/api/schemas/vehicle_schema.py @@ -0,0 +1,50 @@ +from ...alpr.models.vehicle import Vehicle +from marshmallow import fields, EXCLUDE +from marshmallow_sqlalchemy import ModelSchema +from apps import db + +# Defaults & missing +default_missing_custom_data = {"API_KEY": ""} + + +class VehicleSchema(ModelSchema): + class Meta(ModelSchema.Meta): + fields = ("id", "data_type", "version", "epoch_start", "epoch_end", "frame_start", "frame_end", "company_id", + "agent_uid", "agent_version", "agent_type", "camera_id", "gps_latitude", "gps_longitude", "country", + "vehicle_crop_jpeg", "overview_jpeg", "best_uuid", "best_uuid_epoch_ms", "best_image_width", + "best_image_height", "travel_direction", "is_parked", "is_preview", "vehicle_signature", + "vehicle", "time", "best_confidence_percent", "travel_direction_class_tag", "month", "day", + "vehicle_color_name", "vehicle_color_confidence", "vehicle_make_name", "vehicle_make_confidence", + "vehicle_make_model_name", "vehicle_make_model_confidence", "vehicle_body_type_name", + "vehicle_body_type_confidence", "vehicle_year_name", "vehicle_year_confidence", + "vehicle_missing_plate_name", "vehicle_is_vehicle_name", "vehicle_is_vehicle_confidence", + "custom_data") + model = Vehicle + unknown = EXCLUDE + sqla_session = db.session + data_type = fields.String(default=None, missing=None) + version = fields.Integer(default=None, missing=None) + epoch_start = fields.Integer(default=None, missing=None) + epoch_end = fields.Integer(default=None, missing=None) + frame_start = fields.Integer(default=None, missing=None) + frame_end = fields.Integer(default=None, missing=None) + company_id = fields.String(default=None, missing=None) + agent_uid = fields.String(default=None, missing=None) + agent_version = fields.String(default=None, missing=None) + agent_type = fields.String(default=None, missing=None) + camera_id = fields.Integer(default=None, missing=None) + gps_latitude = fields.Integer(default=None, missing=None) + gps_longitude = fields.Integer(default=None, missing=None) + country = fields.String(default=None, missing=None) + vehicle_crop_jpeg = fields.String(default=None, missing=None) + overview_jpeg = fields.String(default=None, missing=None) + best_uuid = fields.String(default=None, missing=None) + best_uuid_epoch_ms = fields.Integer(default=None, missing=None) + best_image_width = fields.Integer(default=None, missing=None) + best_image_height = fields.Integer(default=None, missing=None) + travel_direction = fields.Integer(default=None, missing=None) + is_parked = fields.Boolean(default=None, missing=None) + is_preview = fields.Boolean(default=None, missing=None) + vehicle_signature = fields.String(default=None, missing=None) + vehicle = fields.Dict(default={}, missing={}) + custom_data = fields.Dict(default=default_missing_custom_data, missing=default_missing_custom_data) diff --git a/apps/api/service/alpr_alert_service.py b/apps/api/service/alpr_alert_service.py new file mode 100644 index 0000000..c0f0e91 --- /dev/null +++ b/apps/api/service/alpr_alert_service.py @@ -0,0 +1,33 @@ +import datetime +from flask_restx import Resource +from apps.alpr.models.alpr_alert import ALPRAlert +import apps.alpr.beautify as beautify +from apps.messages import Messages + +message = Messages.message + + +class ALPRAlertService(Resource): + def create(self, request_data: ALPRAlert): + new_alpr_alert = ALPRAlert() + new_alpr_alert.data_type = request_data.data_type + new_alpr_alert.version = request_data.version + new_alpr_alert.epoch_time = request_data.epoch_time + new_alpr_alert.agent_uid = request_data.agent_uid + new_alpr_alert.alert_list = request_data.alert_list + new_alpr_alert.alert_list_id = request_data.alert_list_id + new_alpr_alert.site_name = request_data.site_name + new_alpr_alert.camera_name = request_data.camera_name + new_alpr_alert.camera_number = request_data.camera_number + new_alpr_alert.plate_number = request_data.plate_number + new_alpr_alert.description = request_data.description + new_alpr_alert.list_type = request_data.list_type + new_alpr_alert.group = request_data.group + new_alpr_alert.custom_data = request_data.custom_data + + # Custom + new_alpr_alert.best_confidence_percent = beautify.round_percentage(request_data.group['best_confidence']) + new_alpr_alert.travel_direction_class_tag = beautify.direction(request_data.group['travel_direction']) + + new_alpr_alert.save() + return new_alpr_alert diff --git a/apps/api/service/alpr_group_service.py b/apps/api/service/alpr_group_service.py new file mode 100644 index 0000000..31dd1b8 --- /dev/null +++ b/apps/api/service/alpr_group_service.py @@ -0,0 +1,57 @@ +import datetime + +from flask_restx import Resource +from apps.alpr.models.alpr_group import ALPRGroup +import apps.alpr.beautify as beautify +import time + + +class ALPRGroupService(Resource): + def create(self, request_data: ALPRGroup): + new_alpr_group = ALPRGroup() + new_alpr_group.data_type = request_data.data_type + new_alpr_group.version = request_data.version + new_alpr_group.epoch_start = request_data.epoch_start + new_alpr_group.epoch_end = request_data.epoch_end + new_alpr_group.frame_start = request_data.frame_start + new_alpr_group.frame_end = request_data.frame_end + new_alpr_group.company_id = request_data.company_id + new_alpr_group.agent_uid = request_data.agent_uid + new_alpr_group.agent_version = request_data.agent_version + new_alpr_group.agent_type = request_data.agent_type + new_alpr_group.camera_id = request_data.camera_id + new_alpr_group.gps_latitude = request_data.gps_latitude + new_alpr_group.gps_longitude = request_data.gps_longitude + new_alpr_group.country = request_data.country + new_alpr_group.uuids = request_data.uuids + new_alpr_group.vehicle_path = request_data.vehicle_path + new_alpr_group.plate_indexes = request_data.plate_indexes + new_alpr_group.candidates = request_data.candidates + new_alpr_group.best_plate = request_data.best_plate + new_alpr_group.best_confidence = request_data.best_confidence + new_alpr_group.best_plate_number = request_data.best_plate_number + new_alpr_group.best_region = request_data.best_region + new_alpr_group.best_region_confidence = request_data.best_region_confidence + new_alpr_group.matches_template = request_data.matches_template + new_alpr_group.plate_path = request_data.plate_path + new_alpr_group.vehicle_crop_jpeg = request_data.vehicle_crop_jpeg + new_alpr_group.overview_jpeg = request_data.overview_jpeg + new_alpr_group.best_uuid = request_data.best_uuid + new_alpr_group.best_uuid_epoch_ms = request_data.best_uuid_epoch_ms + new_alpr_group.best_image_width = request_data.best_image_width + new_alpr_group.best_image_height = request_data.best_image_height + new_alpr_group.travel_direction = request_data.travel_direction + new_alpr_group.is_parked = request_data.is_parked + new_alpr_group.is_preview = request_data.is_preview + new_alpr_group.vehicle_signature = request_data.vehicle_signature + new_alpr_group.vehicle = request_data.vehicle + new_alpr_group.web_server_config = request_data.web_server_config + new_alpr_group.direction_of_travel_id = request_data.direction_of_travel_id + new_alpr_group.custom_data = request_data.custom_data + + # Custom + new_alpr_group.best_confidence_percent = beautify.round_percentage(str(request_data.best_confidence)) + new_alpr_group.travel_direction_class_tag = beautify.direction(request_data.travel_direction) + + new_alpr_group.save() + return new_alpr_group diff --git a/apps/api/service/cache_service.py b/apps/api/service/cache_service.py new file mode 100644 index 0000000..038e06a --- /dev/null +++ b/apps/api/service/cache_service.py @@ -0,0 +1,121 @@ +from rq import Retry +import json +import logging +from datetime import datetime + +from apps import default_q +from apps.alpr.enums import DataType +from apps.alpr.models.cache import Cache, CameraCache +from apps.alpr.models.custom_alert import CustomAlert +from apps.alpr.models.settings import AgentSettings, CameraSettings +from apps.alpr.queue import send_alert +from apps.messages import Messages + +message = Messages.message + + +class CacheService: + foreign_id = None + now = datetime.now() + this_year = now.year + # From 0 - 11 not 1 - 12 + this_month = now.month - 1 + + def __init__(self, request_json: json, foreign_id: int): + self.foreign_id = foreign_id + self.request = request_json + + # Load the cache if it already exists + self.cache = Cache.filter_by_year() + + # Create a row if the year doesn't exist + if not self.cache: + self.cache = Cache() + + def update(self): + try: + if self.request['data_type'] == DataType.GROUP: + # Increase counters + self.cache.all_time_plates_captured += 1 + self.cache.month[self.this_month]['license_plates_captured'] += 1 + # Objects + self.cache.month[self.this_month]['cameras'] = \ + self.increase_dict_value_count("cameras", self.request['camera_id']) + + self.cache.month[self.this_month]['regions'] = \ + self.increase_dict_value_count("regions", self.request['best_region']) + + # Cache + camera_id_cache = CameraCache.filter_by_camera_id(self.request['camera_id']) + if camera_id_cache is None: + camera_id_cache = CameraCache(int(self.request['camera_id']), + self.request['web_server_config']['camera_label']) + else: + # Always overwrite the user definable values, they may have changed + camera_id_cache.camera_label = self.request['web_server_config']['camera_label'] + camera_id_cache.gps_latitude = self.request['gps_latitude'] + camera_id_cache.gps_longitude = self.request['gps_longitude'] + camera_id_cache.country = self.request['country'] + camera_id_cache.save() + + # Settings + + # Agents + agent_uid = AgentSettings.filter_by_agent_uid(self.request['agent_uid']) + + if agent_uid is None: + agent_uid = AgentSettings(self.request['agent_uid'], self.request['web_server_config']['agent_label']) + else: + # Always overwrite the label, it may have changed + agent_uid.agent_label = self.request['web_server_config']['agent_label'] + agent_uid.save() + + # Cameras + camera_id = CameraSettings.filter_by_camera_id(self.request['camera_id']) + + if camera_id is None: + camera_id = CameraSettings(self.request['camera_id'], self.request['web_server_config']['camera_label']) + else: + # Always overwrite the label, it may have changed + camera_id.camera_label = self.request['web_server_config']['camera_label'] + camera_id.save() + + # Check to see if we need to send an alert + custom_alert = CustomAlert.filter_by_license_plate(self.request['best_plate_number']) + if custom_alert: + # Send the id to the custom alert to the queue to notify recipients + default_q.enqueue(send_alert, custom_alert.id) + + elif self.request['data_type'] == DataType.ALERT: + self.cache.all_time_alerts += 1 + self.cache.month[self.this_month]['alerts'] += 1 + elif self.request['data_type'] == DataType.VEHICLE: + self.cache.all_time_vehicles += 1 + self.cache.month[self.this_month]['vehicles'] += 1 + + # Update/save the cache + self.cache.save() + + # Prevent circular/infinite loop import + from apps.alpr.queue import download_plate_image + + # Send it to the queue to download the uuid.jpg from the origin agent + default_q.enqueue(download_plate_image, self.request['agent_uid'], self.request['best_uuid'], + self.request['data_type'], self.foreign_id, retry=Retry(max=5, interval=60)) + + return self.cache + except Exception as ex: + logging.exception(ex) + raise Exception(ex) + + def increase_dict_value_count(self, dict_index: str, key: str) -> {}: + keys_values = dict(self.cache.month[self.this_month][dict_index]) + if key in keys_values.keys(): + # Increase the counter/value if the key is already in the dictionary + keys_values[key] += 1 + else: + # Add another key value pair to the dictionary if the pair does not already exist + keys_values.update({key: 1}) + + # Sort the dictionary from greatest to least + return dict(sorted(keys_values.items(), key=lambda count: count[1], reverse=True)) diff --git a/apps/api/service/vehicle_service.py b/apps/api/service/vehicle_service.py new file mode 100644 index 0000000..4d58152 --- /dev/null +++ b/apps/api/service/vehicle_service.py @@ -0,0 +1,63 @@ +import datetime + +from flask_restx import Resource +from apps.alpr.models.vehicle import Vehicle +import apps.alpr.beautify as beautify +from apps.messages import Messages + +message = Messages.message + + +class VehicleService(Resource): + def create(self, request_data: Vehicle): + new_vehicle = Vehicle() + + new_vehicle.data_type = request_data.data_type + new_vehicle.version = request_data.version + new_vehicle.epoch_start = request_data.epoch_start + new_vehicle.epoch_end = request_data.epoch_end + new_vehicle.frame_start = request_data.frame_start + new_vehicle.frame_end = request_data.frame_end + new_vehicle.company_id = request_data.company_id + new_vehicle.agent_uid = request_data.agent_uid + new_vehicle.agent_version = request_data.agent_version + new_vehicle.agent_type = request_data.agent_type + new_vehicle.camera_id = request_data.camera_id + new_vehicle.gps_latitude = request_data.gps_latitude + new_vehicle.gps_longitude = request_data.gps_longitude + new_vehicle.country = request_data.country + new_vehicle.vehicle_crop_jpeg = request_data.vehicle_crop_jpeg + new_vehicle.overview_jpeg = request_data.overview_jpeg + new_vehicle.best_uuid = request_data.best_uuid + new_vehicle.best_uuid_epoch_ms = request_data.best_uuid_epoch_ms + new_vehicle.best_image_width = request_data.best_image_width + new_vehicle.best_image_height = request_data.best_image_height + new_vehicle.travel_direction = request_data.travel_direction + new_vehicle.is_parked = request_data.is_parked + new_vehicle.is_preview = request_data.is_preview + new_vehicle.vehicle_signature = request_data.vehicle_signature + new_vehicle.vehicle = request_data.vehicle + new_vehicle.custom_data = request_data.custom_data + + # Custom + new_vehicle.vehicle_color_name = beautify.name(request_data.vehicle['color'][0]['name']) + new_vehicle.vehicle_color_confidence = beautify.round_percentage( + request_data.vehicle['color'][0]['confidence']) + new_vehicle.vehicle_make_name = beautify.name(request_data.vehicle['make'][0]['name']) + new_vehicle.vehicle_make_confidence = beautify.round_percentage(request_data.vehicle['make'][0]['confidence']) + new_vehicle.vehicle_make_model_name = beautify.name(request_data.vehicle['make_model'][0]['name']) + new_vehicle.vehicle_make_model_confidence = beautify.round_percentage( + request_data.vehicle['make_model'][0]['confidence']) + new_vehicle.vehicle_body_type_name = beautify.name(request_data.vehicle['body_type'][0]['name']) + new_vehicle.vehicle_body_type_confidence = beautify.round_percentage( + request_data.vehicle['body_type'][0]['confidence']) + new_vehicle.vehicle_year_name = request_data.vehicle['year'][0]['name'] + new_vehicle.vehicle_year_confidence = beautify.round_percentage(request_data.vehicle['year'][0]['confidence']) + new_vehicle.vehicle_missing_plate_name = beautify.name(request_data.vehicle['missing_plate'][0]['name']) + new_vehicle.vehicle_is_vehicle_name = beautify.name(request_data.vehicle['is_vehicle'][0]['name']) + new_vehicle.vehicle_is_vehicle_confidence = beautify.round_percentage( + request_data.vehicle['is_vehicle'][0]['confidence']) + new_vehicle.travel_direction_class_tag = beautify.direction(request_data.travel_direction) + + new_vehicle.save() + return new_vehicle diff --git a/apps/authentication/__init__.py b/apps/authentication/__init__.py new file mode 100644 index 0000000..c7cbb6e --- /dev/null +++ b/apps/authentication/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +blueprint = Blueprint( + 'authentication_blueprint', + __name__, + url_prefix='' +) diff --git a/apps/authentication/email.py b/apps/authentication/email.py new file mode 100644 index 0000000..8a464e5 --- /dev/null +++ b/apps/authentication/email.py @@ -0,0 +1,17 @@ +from flask_mail import Message +from apps import mail +from apps.config import Config +default_sender = Config.MAIL_DEFAULT_SENDER + + +def send_email(to, subject, template): + # with app.app_context(): + msg = Message( + subject, + recipients=[to], + html=template, + sender=default_sender + ) + + mail.send(msg) + \ No newline at end of file diff --git a/apps/authentication/forms.py b/apps/authentication/forms.py new file mode 100644 index 0000000..fe0ec61 --- /dev/null +++ b/apps/authentication/forms.py @@ -0,0 +1,88 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, SubmitField +from wtforms.validators import Email, DataRequired, EqualTo, InputRequired + +from apps.authentication.models import User +from apps.helpers import password_validate +from apps.authentication.util import verify_pass + + +class LoginForm(FlaskForm): + username = StringField('Username', + id='username_login', + validators=[DataRequired()]) + password = PasswordField('Password', + id='pwd_login', + validators=[DataRequired()]) + + def validate(self): + + user = User.query.filter_by(username=self.username.data).first() + + if not user: + # self.username.errors.append('Unknown email') + return False + + if user and not verify_pass(self.password.data, user.password): + # if not user.verify_pass(self.password.data): + # self.password.errors.append('Invalid password') + return False + + return True + + +class CreateAccountForm(FlaskForm): + username = StringField('Username', + id='username_create', + validators=[DataRequired()]) + email = StringField('Email', id='email_create', + validators=[DataRequired(), Email()]) + password = PasswordField('Password', + id='id_password1', + validators=[DataRequired(), EqualTo('password', message="Passwords must match")]) + password_check = PasswordField('Password Check', + id='id_password2', + validators=[DataRequired(), EqualTo('password', message="Passwords must match")]) + + def validate(self): + user = User.query.filter_by(email=self.email.data).first() + if user: + # self.email.errors.append("Email already registered") + return False + + user = User.query.filter_by(username=self.username.data).first() + if user: + # self.email.errors.append("Username already exists.") + return False + + valid_pwd = password_validate(self.password.data) + if not valid_pwd: + return False + + return True + + +class UserProfileForm(FlaskForm): + + full_name = StringField('Full Name', + id='full_name_create', + validators=[DataRequired()]) + address = StringField('Address', + id='address_create', + validators=[DataRequired()]) + bio = StringField('Bio', + id='bio') + zipcode = StringField('Zipcode', + id='zipcode_create') + phone = StringField('Phone', + id='phone_create', + validators=[DataRequired()]) + email = StringField('Email', + id='email_create', + validators=[DataRequired()]) + website = StringField('Website', + id='website_create') + image = StringField('Image', + id='image_create', + validators=[DataRequired()]) + diff --git a/apps/authentication/models.py b/apps/authentication/models.py new file mode 100644 index 0000000..d2482a6 --- /dev/null +++ b/apps/authentication/models.py @@ -0,0 +1,187 @@ +import base64 +import os +from pathlib import Path + +from flask_login import UserMixin +from apps import db, login_manager +from apps.authentication.util import hash_pass +from sqlalchemy.exc import SQLAlchemyError +from apps.exceptions.exception import InvalidUsage +import datetime as dt +from sqlalchemy.orm import relationship +from flask_dance.consumer.storage.sqla import OAuthConsumerMixin +from apps.config import Config + +RoleType = Config.USERS_ROLES +Status = Config.USERS_STATUS +VERIFIED_EMAIL = Config.VERIFIED_EMAIL + +with open(Path(os.path.abspath(os.path.dirname(__file__ + "../../../../") + + "/apps/static/assets/img/user/avatar-2.jpg")).absolute(), "rb") as jpg_file: + default_user_avatar = base64.b64encode(jpg_file.read()).decode("utf-8") + + +class User(db.Model, UserMixin): + __tablename__ = 'users' + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(64), unique=True) + email = db.Column(db.String(100), unique=True) + password = db.Column(db.LargeBinary) + avatar = db.Column(db.String, nullable=True, default=default_user_avatar) + role = db.Column(db.Integer(), default=RoleType['USER'], nullable=False) + status = db.Column(db.Integer(), default=Status['ACTIVE'], nullable=False) + failed_logins = db.Column(db.Integer(), default=0) + + api_token = db.Column(db.String(100)) + api_token_ts = db.Column(db.String(100)) + + verified_email = db.Column(db.Integer(), default=VERIFIED_EMAIL['not-verified'], nullable=False) + + oauth_twitter = db.Column(db.String(100), nullable=True) + oauth_github = db.Column(db.String(100), nullable=True) + + date_created = db.Column(db.DateTime, default=dt.datetime.utcnow()) + date_modified = db.Column(db.DateTime, default=db.func.current_timestamp(), onupdate=db.func.current_timestamp()) + + def __init__(self, **kwargs): + for property, value in kwargs.items(): + # depending on whether value is an iterable or not, we must + # unpack its value (when **kwargs is request.form, some values + # will be a 1-element list) + if hasattr(value, '__iter__') and not isinstance(value, str): + # the ,= unpack of a singleton fails PEP8 (travis flake8 test) + value = value[0] + + if property == 'password': + value = hash_pass(value) # we need bytes here (not plain str) + + setattr(self, property, value) + + def __repr__(self): + return str(self.username) + + @classmethod + def find_by_email(cls, _email: str) -> "User": + return cls.query.filter_by(email=_email).first() + + @classmethod + def find_by_username(cls, _username: str) -> "User": + return cls.query.filter_by(username=_username).first() + + @classmethod + def find_by_id(cls, _id: int) -> "User": + return cls.query.filter_by(id=_id).first() + + @classmethod + def find_by_api_token(cls, token: str) -> "User": + return cls.query.filter_by(api_token=token).first() + + @classmethod + def get_list_of_users_w_user_profiles(cls) -> []: + users = cls.query.all() + users_list = [] + for user in users: + for profile in UserProfile.query.filter_by(user=user.id): + if user.status == Status['ACTIVE']: + users_list.append({ + 'id': user.id, + 'email': user.email, + 'full_name': profile.full_name, + 'username': user.username + }) + return users_list + + @classmethod + def get_number_of_users(cls) -> int: + return cls.query.count() + + def save(self) -> None: + try: + # Make the first user admin + if self.get_number_of_users() == 0: + self.role = RoleType['ADMIN'] + db.session.add(self) + db.session.commit() + + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) + + def delete_from_db(self) -> None: + try: + db.session.delete(self) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) + return + + +class UserProfile(db.Model): + __tablename__ = 'user_profiles' + + id = db.Column(db.Integer, primary_key=True) + full_name = db.Column(db.String, nullable=True, default='') + bio = db.Column(db.String, nullable=True, default='') + address = db.Column(db.String, nullable=True, default='') + zipcode = db.Column(db.String, nullable=True, default='') + phone = db.Column(db.String, nullable=True, default='') + email = db.Column(db.String, unique=True, nullable=True) + website = db.Column(db.String, nullable=True, default='') + image = db.Column(db.String, nullable=True, default=default_user_avatar) + timezone = db.Column(db.String, default="UTC") + user = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="cascade"), nullable=False) + user_id = relationship(User, uselist=False, backref="profile") + date_created = db.Column(db.DateTime, default=dt.datetime.utcnow()) + date_modified = db.Column(db.DateTime, default=db.func.current_timestamp(), onupdate=db.func.current_timestamp()) + + @classmethod + def find_by_id(cls, _id: int) -> "UserProfile": + return cls.query.filter_by(id=_id).first() + + @classmethod + def find_by_user_id(cls, _id: int) -> "UserProfile": + return cls.query.filter_by(user=_id).first() + + def save(self) -> None: + try: + db.session.add(self) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) + + def delete_from_db(self) -> None: + try: + db.session.delete(self) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + db.session.close() + error = str(e.__dict__['orig']) + raise InvalidUsage(error, 422) + return + + +@login_manager.user_loader +def user_loader(id): + return User.query.filter_by(id=id).first() + + +@login_manager.request_loader +def request_loader(request): + username = request.form.get('username') + user = User.query.filter_by(username=username).first() + return user if user else None + + +class OAuth(OAuthConsumerMixin, db.Model): + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="cascade"), nullable=False) + user = db.relationship(User) diff --git a/apps/authentication/oauth.py b/apps/authentication/oauth.py new file mode 100644 index 0000000..e56bb98 --- /dev/null +++ b/apps/authentication/oauth.py @@ -0,0 +1,104 @@ +import os +from flask_login import current_user, login_user +from flask_dance.consumer import oauth_authorized +from flask_dance.contrib.github import github, make_github_blueprint +from flask_dance.consumer.storage.sqla import SQLAlchemyStorage +from flask_dance.contrib.twitter import twitter, make_twitter_blueprint +from sqlalchemy.orm.exc import NoResultFound +from apps.authentication.signals import user_saved_signals +from apps.helpers import createAccessToken, get_ts +from apps.config import Config +from .models import User, db, OAuth +from flask import redirect, url_for + +STATUS_SUSPENDED = Config.USERS_STATUS['SUSPENDED'] + +github_blueprint = make_github_blueprint( + client_id=Config.GITHUB_ID, + client_secret=Config.GITHUB_SECRET, + scope = 'user', + storage=SQLAlchemyStorage( + OAuth, + db.session, + user=current_user, + user_required=False, + ), +) + +twitter_blueprint = make_twitter_blueprint( + api_key=Config.TWITTER_ID, + api_secret=Config.TWITTER_SECRET, + storage=SQLAlchemyStorage( + OAuth, + db.session, + user=current_user, + user_required=False, + ), +) + +@oauth_authorized.connect_via(github_blueprint) +def github_logged_in(blueprint, token): + info = github.get("/user") + + if info.ok: + + account_info = info.json() + username = account_info["login"] + + query = User.query.filter_by(oauth_github=username) + try: + + user = query.one() + + # Take into account the current user state + if STATUS_SUSPENDED == user.status: + return redirect('/login?oautherr=suspended') + + login_user(user) + + except NoResultFound: + + # Save to db + user = User() + user.username = '(gh)' + username + user.oauth_github = username + user.api_token = createAccessToken() + user.api_token_ts = get_ts() + user.save() + + # send signal for create profile + user_saved_signals.send({"user_id":user.id, "email": user.email}) + login_user(user) + +@oauth_authorized.connect_via(twitter_blueprint) +def twitter_logged_in(blueprint, token): + info = twitter.get("account/settings.json") + + if info.ok: + account_info = info.json() + username = account_info["screen_name"] + + query = User.query.filter_by(oauth_twitter=username) + try: + + user = query.one() + + # Take into account the current user state + if STATUS_SUSPENDED == user.status: + return redirect('/login?oautherr=suspended') + + login_user(user) + + except NoResultFound: + + # Save to db + user = User() + user.username = '(tw)' + username + user.oauth_github = username + user.api_token = createAccessToken() + user.api_token_ts = get_ts() + user.save() + + # send signal for create profile + user_saved_signals.send({"user_id":user.id, "email": user.email}) + login_user(user) diff --git a/apps/authentication/receiver.py b/apps/authentication/receiver.py new file mode 100644 index 0000000..9d4ca54 --- /dev/null +++ b/apps/authentication/receiver.py @@ -0,0 +1,26 @@ +from .models import UserProfile, User + + +class UserProfileReceiver: + def create_profile_by_user(self, context): + """ Create profile """ + # profile = UserProfile.query.filter_by(user=context['user_id']).first() + profile = UserProfile.find_by_user_id(context['user_id']) + if profile is None: + create_profile = UserProfile() + if context['email'] is None: + create_profile.user = context['user_id'] + create_profile.save() + + create_profile.user = context['user_id'] + create_profile.email = context['email'] + create_profile.save() + return True + + def delete_profile_by_user(self, context): + """ delete profile """ + profile = UserProfile.find_by_user_id(context['user_id']) + if profile is not None: + profile.delete_from_db() + return True + diff --git a/apps/authentication/routes.py b/apps/authentication/routes.py new file mode 100644 index 0000000..f663b57 --- /dev/null +++ b/apps/authentication/routes.py @@ -0,0 +1,225 @@ +from flask import Flask +from flask import render_template, redirect, request, url_for +from flask_login import ( + current_user, + login_user, + logout_user, login_required, AnonymousUserMixin +) + + +from apps import login_manager +from apps import ip_ban +from apps.authentication import blueprint +from apps.authentication.forms import LoginForm, CreateAccountForm +from apps.authentication.models import User +from apps.authentication.signals import user_saved_signals +from apps.authentication.util import verify_pass +from apps.config import Config +from apps.helpers import createAccessToken, emailValidate, get_ts, password_validate +from apps.messages import Messages + +message = Messages.message + +login_limit = Config.LOGIN_ATTEMPT_LIMIT + +# User States +STATUS_SUSPENDED = Config.USERS_STATUS['SUSPENDED'] +STATUS_ACTIVE = Config.USERS_STATUS['ACTIVE'] + +# Users Roles +ROLE_ADMIN = Config.USERS_ROLES['ADMIN'] +ROLE_USER = Config.USERS_ROLES['USER'] + +upload_folder_name = Config.UPLOAD_FOLDER +download_folder_name = Config.DOWNLOAD_FOLDER +app = Flask(__name__) +app.config['UPLOAD_FOLDER'] = Config.UPLOAD_FOLDER + + +class Anonymous(AnonymousUserMixin): + def __init__(self): + self.username = 'Guest' + + +login_manager.anonymous_user = Anonymous + + +@blueprint.route('/') +def route_default(): + return redirect(url_for('authentication_blueprint.login')) + + +# Login & Registration +@blueprint.route('/login', methods=['GET', 'POST']) +def login(): + """ + Login View + """ + template_name = 'accounts/login.html' + login_form = LoginForm(request.form) + + if 'login' in request.form: + + # read form data + username = request.form['username'] + password = request.form['password'] + + valid_email = emailValidate(username) + + if valid_email: + user = User.find_by_email(username) + else: + # Locate user + user = User.find_by_username(username) + + # if user not found + if not user: + ip_ban.add() + return render_template(template_name, + msg=message['wrong_user_or_password'], + form=login_form) + + # Check user is suspended + if STATUS_SUSPENDED == user.status: + return render_template(template_name, + msg=message['suspended_account_please_contact_support'], + form=login_form) + + if user.failed_logins >= login_limit: + user.status = STATUS_SUSPENDED + user.save() + return render_template(template_name, + msg=message['suspended_account_maximum_nb_of_tries_exceeded'], + form=login_form) + + # Check the password + if user and not verify_pass(password, user.password): + user.failed_logins += 1 + user.save() + ip_ban.add() + return render_template(template_name, + msg=message['incorrect_password'], + form=login_form) + login_user(user) + user.failed_logins = 0 + user.save() + + return redirect(url_for('home_blueprint.index')) + + if not current_user.is_authenticated: + + # we might have a redirect from OAuth + msg = request.args.get('oautherr') + + if msg and 'suspended' in msg: + msg = message['suspended_account_please_contact_support'] + + return render_template(template_name, + form=login_form, + msg=msg) + + return redirect(url_for('home_blueprint.index')) + + +@blueprint.route('/logout') +@login_required +def logout(): + """ Logout View """ + logout_user() + return redirect(url_for('authentication_blueprint.login')) + + +@blueprint.route('/register', methods=['GET', 'POST']) +def register(): + """ + User register view + """ + if current_user.username == "Guest": + number_of_users = User.get_number_of_users() + if number_of_users != 0: + return render_template('home/page-403.html') + elif current_user.is_authenticated: + if current_user.role != ROLE_ADMIN: + return render_template('home/page-403.html') + + # already logged in + if current_user.is_authenticated: + return redirect('/') + + template_name = 'accounts/register.html' + create_account_form = CreateAccountForm(request.form) + + if 'register' in request.form: + username = request.form['username'] + email = request.form['email'] + password = request.form['password'] + password2 = request.form['password_check'] + + if password != password2: + return render_template(template_name, + msg=message['pwd_not_match'], + success=False, + form=create_account_form) + + # Check if username exists + user = User.find_by_username(username) + if user: + return render_template(template_name, + msg=message['username_already_registered'], + success=False, + form=create_account_form) + + # Check if email exists + user = User.find_by_email(email) + if user: + return render_template(template_name, + msg=message['email_already_registered'], + success=False, + form=create_account_form) + + valid_pwd = password_validate(password) + if not valid_pwd: + return render_template(template_name, + msg=valid_pwd, + success=False, + form=create_account_form) + + user = User(**request.form) + user.api_token = createAccessToken() + user.api_token_ts = get_ts() + user.save() + + # Force logout + logout_user() + + # send signal for create profile + user_saved_signals.send({"user_id": user.id, "email": user.email}) + + return render_template(template_name, + msg=message['account_created_successfully'], + success=True, + form=create_account_form) + + else: + return render_template(template_name, form=create_account_form) + + +# Errors +@login_manager.unauthorized_handler +def unauthorized_handler(): + return render_template('home/page-403.html'), 403 + + +@blueprint.errorhandler(403) +def access_forbidden(error): + return render_template('home/page-403.html'), 403 + + +@blueprint.errorhandler(404) +def not_found_error(error): + return render_template('home/page-404.html'), 404 + + +@blueprint.errorhandler(500) +def internal_error(error): + return render_template('home/page-500.html'), 500 diff --git a/apps/authentication/signals.py b/apps/authentication/signals.py new file mode 100644 index 0000000..8ccbd25 --- /dev/null +++ b/apps/authentication/signals.py @@ -0,0 +1,15 @@ +from blinker import Namespace +from apps.authentication.receiver import UserProfileReceiver + + +event_signals = Namespace() + +tbls = UserProfileReceiver() + +user_saved_signals = event_signals.signal('user-saved-signals') + +user_saved_signals.connect(tbls.create_profile_by_user) + +# delete user profile +delete_user_signals = event_signals.signal('delete-user-signals') +delete_user_signals.connect(tbls.delete_profile_by_user) diff --git a/apps/authentication/token.py b/apps/authentication/token.py new file mode 100644 index 0000000..7229899 --- /dev/null +++ b/apps/authentication/token.py @@ -0,0 +1,24 @@ +from itsdangerous import URLSafeTimedSerializer +from apps.config import Config +secret_key = Config.SECRET_KEY +secret_pwd = Config.SECURITY_PASSWORD_SALT + + +def generate_confirmation_token(email): + """ generate token""" + serializer = URLSafeTimedSerializer(secret_key) + return serializer.dumps(email, salt=secret_pwd) + + +def confirm_token(token, expiration=3600): + """ confirm token """ + serializer = URLSafeTimedSerializer(secret_key) + try: + email = serializer.loads( + token, + salt=secret_pwd, + max_age=expiration + ) + except: + return False + return email diff --git a/apps/authentication/util.py b/apps/authentication/util.py new file mode 100644 index 0000000..2b1e901 --- /dev/null +++ b/apps/authentication/util.py @@ -0,0 +1,47 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +import os +import hashlib +import binascii + +# Inspiration -> https://www.vitoshacademy.com/hashing-passwords-in-python/ + + +def hash_pass(password): + """Hash a password for storing.""" + + salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii') + pwdhash = hashlib.pbkdf2_hmac('sha512', password.encode('utf-8'), + salt, 100000) + pwdhash = binascii.hexlify(pwdhash) + return (salt + pwdhash) # return bytes + + +def verify_pass(provided_password, stored_password): + """Verify a stored password against one provided by user""" + + stored_password = stored_password.decode('ascii') + salt = stored_password[:64] + stored_password = stored_password[64:] + pwdhash = hashlib.pbkdf2_hmac('sha512', + provided_password.encode('utf-8'), + salt.encode('ascii'), + 100000) + pwdhash = binascii.hexlify(pwdhash).decode('ascii') + return pwdhash == stored_password + +def new_password_should_be_different(old_pwd, new_pwd): + """Verify a stored password against one provided by user""" + + old_pwd = old_pwd.decode('ascii') + salt = old_pwd[:64] + old_pwd = old_pwd[64:] + pwdhash = hashlib.pbkdf2_hmac('sha512', + new_pwd.encode('utf-8'), + salt.encode('ascii'), + 100000) + pwdhash = binascii.hexlify(pwdhash).decode('ascii') + return pwdhash == old_pwd \ No newline at end of file diff --git a/apps/config.py b/apps/config.py new file mode 100644 index 0000000..2b3f683 --- /dev/null +++ b/apps/config.py @@ -0,0 +1,63 @@ +import configparser +import os +from os.path import exists +import secrets + +basedir = os.path.abspath(os.path.dirname(__file__)) + + +class Config(object): + RQ_DASHBOARD_REDIS_URL = "redis://127.0.0.1:6379" + UPLOAD_FOLDER = 'apps/uploads/' + DOWNLOAD_FOLDER = 'apps/downloads/' + ALLOWED_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'} + + USERS_ROLES = {'ADMIN': 1, 'USER': 2} + USERS_STATUS = {'ACTIVE': 1, 'SUSPENDED': 2} + + # check verified_email + VERIFIED_EMAIL = {'verified': 1, 'not-verified': 2} + + LOGIN_ATTEMPT_LIMIT = 7 + + DEFAULT_IMAGE_URL = 'static/assets/images/' + + # Set up secrets + if not exists("secrets.ini"): + config = configparser.ConfigParser() + config['app'] = {'secret_key': secrets.token_hex(), + 'secret_password_salt': secrets.SystemRandom().getrandbits(128)} + with open("secrets.ini", 'w', encoding='utf-8') as configini: + config.write(configini) + + config = configparser.ConfigParser() + config.read("secrets.ini") + + SECRET_KEY = os.getenv('SECRET_KEY', config['app']['secret_key']) + SECURITY_PASSWORD_SALT = os.getenv('SECURITY_PASSWORD_SALT', config['app']['secret_password_salt']) + + SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'db/app.sqlite') + SQLALCHEMY_BINDS = { + 'alpr_alert': 'sqlite:///' + os.path.join(basedir, 'db/alpr_alert.sqlite'), + 'alpr_group': 'sqlite:///' + os.path.join(basedir, 'db/alpr_group.sqlite'), + 'cache': 'sqlite:///' + os.path.join(basedir, 'db/cache.sqlite'), + 'custom_alert': 'sqlite:///' + os.path.join(basedir, 'db/custom_alert.sqlite'), + 'settings': 'sqlite:///' + os.path.join(basedir, 'db/settings.sqlite'), + 'vehicle': 'sqlite:///' + os.path.join(basedir, 'db/vehicle.sqlite'), + } + SQLALCHEMY_TRACK_MODIFICATIONS = False + + +class ProductionConfig(Config): + DEBUG = False + + +class DebugConfig(Config): + DEBUG = True + + +# Load all possible configurations +config_dict = { + 'Production': ProductionConfig, + 'Debug': DebugConfig +} diff --git a/apps/exceptions/exception.py b/apps/exceptions/exception.py new file mode 100644 index 0000000..6e3ab02 --- /dev/null +++ b/apps/exceptions/exception.py @@ -0,0 +1,15 @@ +class InvalidUsage(Exception): + status_code = 400 + + def __init__(self, message, status_code=None, payload=None): + Exception.__init__(self) + self.message = message + if status_code is not None: + self.status_code = status_code + self.payload = payload + + def to_dict(self): + rv = dict(self.payload or ()) + rv['message'] = self.message + + return rv diff --git a/apps/helpers.py b/apps/helpers.py new file mode 100644 index 0000000..af7b82f --- /dev/null +++ b/apps/helpers.py @@ -0,0 +1,252 @@ +import datetime +import os +import uuid +import re + +import pytz +from colorama import Fore, Style +from apps.authentication.models import User, UserProfile +import ipaddress +from apps.messages import Messages +from functools import wraps +from flask import request +from uuid import uuid4 +import time +import phonenumbers + +message = Messages.message + + +def setChoices(current_user, user_ids: []) -> []: + users = User.query + choices = [] + for user in users: + if user.username != current_user.username: + user_profile = UserProfile.find_by_user_id(user.id) + selected = False + if user_ids is not None: + if str(user.id) in user_ids: + selected = True + choices.append({ + 'value': user.id, + 'label': user_profile.full_name + " (" + user.email + ")", + 'selected': selected + }) + return choices + + +def shorten_description(description: str, n=20) -> str: + split_description = str(description).split(' ') + shorten_desc = None + + if len(split_description) <= n: + return description + + if len(split_description) > n: + for i in range(n): + if i == 0: + shorten_desc = split_description[i] + " " + elif i != n - 1: + shorten_desc = shorten_desc + split_description[i] + " " + elif i == n - 1: + shorten_desc = shorten_desc + " " + split_description[i] + "..." + + return shorten_desc + + +class Timezone: + user = None + profile = None + msecs = False + + def __init__(self, current_user: str, msecs=False): + # Get timezone from the user settings + self.user = User.find_by_username(str(current_user)) + self.profile = UserProfile.find_by_id(self.user.id) + self.msecs = msecs + + def astimezone(self, utc: datetime) -> str: + + if type(utc) is int: + utc = datetime.datetime.utcfromtimestamp(utc / 1000) + + if self.msecs: + schema = "%b %d %Y %I:%M:%S:%f %p %Z" + else: + schema = "%b %d %Y %I:%M:%S %p %Z" + + if self.profile: + return utc.replace(tzinfo=datetime.timezone.utc).astimezone(tz=pytz.timezone(self.profile.timezone)). \ + strftime(schema) + else: + return utc + + def day(self, utc: datetime) -> str: + if type(utc) is int: + utc = datetime.datetime.utcfromtimestamp(utc / 1000) + + return utc.replace(tzinfo=datetime.timezone.utc).astimezone(tz=pytz.timezone(self.profile.timezone)). \ + strftime("%d") + + def month(self, utc: datetime) -> str: + if type(utc) is int: + utc = datetime.datetime.utcfromtimestamp(utc / 1000) + + return utc.replace(tzinfo=datetime.timezone.utc).astimezone(tz=pytz.timezone(self.profile.timezone)). \ + strftime("%b") + + +# Thanks to Tim Pietzcker @stackoverflow.com +# https://stackoverflow.com/a/2532344 +def is_valid_hostname(hostname: str) -> bool: + if len(hostname) > 255: + return False + if hostname[-1] == ".": + hostname = hostname[:-1] # strip exactly one dot from the right, if present + allowed = re.compile("(?!-)[A-Z\d-]{1,63}(? bool: + try: + ipaddress.ip_address(ip) + return True + except ValueError: + return False + + +def is_valid_port(port: int) -> bool: + try: + # Try casting it + if 1 <= port <= 65535: + return True + except Exception: + return False + + +def are_valid_email_recipients(recipients: str) -> bool: + recipients = recipients.split(',') + for recipient in recipients: + if not emailValidate(recipient): + return False + return True + + +def are_valid_sms_recipients(recipients: str) -> bool: + recipients = recipients.split(',') + for recipient in recipients: + try: + if not phonenumbers.is_valid_number(phonenumbers.parse(recipient)): + return False + except: + return False + return True + + +def get_ts(): + return int(time.time()) + + +def password_validate(password): + """ password validate """ + msg = '' + while True: + if len(password) < 6: + msg = "Make sure your password is at lest 6 letters" + return msg + elif re.search('[0-9]', password) is None: + msg = "Make sure your password has a number in it" + return msg + elif re.search('[A-Z]', password) is None: + msg = "Make sure your password has a capital letter in it" + return msg + else: + msg = True + break + + return True + + +def emailValidate(email): + """ validate email """ + regex = re.compile(r'([A-Za-z0-9]+[.-_])*[A-Za-z0-9]+@[A-Za-z0-9-]+(\.[A-Z|a-z]{2,})+') + if re.fullmatch(regex, email): + return True + else: + return False + + +def createFolder(folder_name): + """ create folder for save csv """ + if not os.path.exists(f'{folder_name}'): + os.makedirs(f'{folder_name}') + + return folder_name + + +def unique_file_name(file_name): + """ for Unique file name""" + file_uuid = uuid.uuid4() + return f'{file_uuid}-{file_name}' + + +def errorColor(error): + """ for terminal input error color """ + print(Fore.RED + f'{error}') + print(Style.RESET_ALL) + return True + + +def splitUrlGetFilename(url): + """ image url split and get file name """ + return url.split('/')[-1] + + +def expectedValue(data): + """ key get values """ + values = [] + for k, v in data.items(): + values.append(f'{v}.({k})') + + return ",".join(values) + + +def createAccessToken(): + """ create access token w""" + rand_token = uuid4() + + return f"{str(rand_token)}" + + +# token validate +def token_required(f): + """ check token """ + + @wraps(f) + def decorated(*args, **kwargs): + token = None + if "Authorization" in request.headers: + token = request.headers["Authorization"] + if not token: + return { + "message": "Authentication Token is missing!", + "error": "Unauthorized" + }, 401 + try: + current_user = User.find_by_api_token(token) + if current_user is None: + return { + "message": "Invalid Authentication token!", + "error": "Unauthorized" + }, 401 + # if not current_user["active"]: + # abort(403) + except Exception as e: + return { + "message": "Something went wrong", + "error": str(e) + }, 500 + + return f(current_user, **kwargs) + + return decorated diff --git a/apps/home/__init__.py b/apps/home/__init__.py new file mode 100644 index 0000000..7e295d2 --- /dev/null +++ b/apps/home/__init__.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +blueprint = Blueprint( + 'home_blueprint', + __name__, + url_prefix='' +) diff --git a/apps/home/routes.py b/apps/home/routes.py new file mode 100644 index 0000000..0e70753 --- /dev/null +++ b/apps/home/routes.py @@ -0,0 +1,67 @@ +from apps.alpr.enums import ChartType +from apps.alpr.models.cache import Cache +from apps.alpr.models.alpr_group import ALPRGroup +from apps.alpr.models.alpr_alert import ALPRAlert +from apps.alpr.models.custom_alert import CustomAlert + +from apps.home import blueprint +from flask import render_template, request +from flask_login import login_required, current_user +from jinja2 import TemplateNotFound + + +@blueprint.route('/dashboard') +@login_required +def index(): + cache = Cache().filter_by_year() + alpr_group_records = ALPRGroup().get_dashboard_records() + alpr_alert_records = ALPRAlert().get_dashboard_records() + custom_alerts = CustomAlert().get_dashboard_records(current_user) + + return render_template('home/index.html', segment='index', alpr_group_records=alpr_group_records, + alpr_alert_records=alpr_alert_records, custom_alerts=custom_alerts, + quick_stats=cache.get_quick_stats(), regions=cache.get_us_map_regions(), + us_map_regions=cache.get_us_map_series(), + plates_captured_chart_series=cache.get_chart_series(ChartType.PLATES_CAPTURED_CHART), + alert_chart_series=cache.get_chart_series(ChartType.ALERT_CHART), + top_region_chart_series=cache.get_chart_series(ChartType.TOP_REGION_CHART), + plates_captured_alerts_chart_labels=cache.get_chart_labels(), + top_region_chart_labels=cache.get_chart_labels(ChartType.TOP_REGION_CHART), + number_of_records=cache.get_number_of_records(), + size_of_databases=cache.get_all_db_file_sizes(), top_cameras=cache.get_top_cameras(), + number_of_records_raw=cache.get_number_of_records(raw=True), + size_of_databases_raw=cache.get_all_db_file_sizes(raw=True)) + + +@blueprint.route('/