Files
photoassistant/settings.py

199 lines
12 KiB
Python

from wtforms import SubmitField, StringField, IntegerField, FloatField, HiddenField, validators, Form, SelectField, BooleanField
from flask_wtf import FlaskForm
from flask import request, render_template, redirect, url_for
from sqlalchemy import Sequence
from sqlalchemy.exc import SQLAlchemyError
from status import st, Status
from flask_login import login_required, current_user
from main import db, app, ma
# pylint: disable=no-member
################################################################################
# Class describing AI_MODEL in the database, and via sqlalchemy, connected to the DB as well
################################################################################
class AIModel(db.Model):
__tablename__ = "ai_model"
id = db.Column(db.Integer, primary_key=True )
name = db.Column(db.String)
def __repr__(self):
return f"<id: {self.id}, name: {self.name}>"
################################################################################
# Class describing Settings in the database, and via sqlalchemy, connected to the DB as well
################################################################################
class Settings(db.Model):
id = db.Column(db.Integer, db.Sequence('settings_id_seq'), primary_key=True )
base_path = db.Column(db.String)
import_path = db.Column(db.String)
storage_path = db.Column(db.String)
recycle_bin_path = db.Column(db.String)
metadata_path = db.Column(db.String)
auto_rotate = db.Column(db.Boolean)
default_refimg_model = db.Column(db.Integer,db.ForeignKey('ai_model.id'), unique=True, nullable=False)
default_scan_model = db.Column(db.Integer,db.ForeignKey('ai_model.id'), unique=True, nullable=False)
default_threshold = db.Column(db.Integer)
face_size_limit = db.Column(db.Integer)
scheduled_import_scan = db.Column(db.Integer)
scheduled_storage_scan = db.Column(db.Integer)
scheduled_bin_cleanup = db.Column(db.Integer)
bin_cleanup_file_age = db.Column(db.Integer)
job_archive_age = db.Column(db.Integer)
def __repr__(self):
return f"<id: {self.id}, import_path: {self.import_path}, storage_path: {self.storage_path}, recycle_bin_path: {self.recycle_bin_path}, auto_rotate: {self.auto_rotate}, default_refimg_model: {self.default_refimg_model}, default_scan_model: {self.default_scan_model}, default_threshold: {self.default_threshold}, face_size_limit: {self.face_size_limit}, scheduled_import_scan:{self.scheduled_import_scan}, scheduled_storage_scan: {self.scheduled_storage_scan}, scheduled_bin_cleanup: {self.scheduled_bin_cleanup}, bin_cleanup_file_age: {self.bin_cleanup_file_age}, job_archive_age: {self.job_archive_age}>"
################################################################################
# Helper class that inherits a .dump() method to turn class Settings into json / useful in jinja2
################################################################################
class SettingsSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = Settings
ordered = True
settings_schema = SettingsSchema(many=True)
################################################################################
# Helper class that defines a form for Settings, used to make html <form>
# with field validation (via wtforms)
################################################################################
class SettingsForm(FlaskForm):
id = HiddenField()
base_path = StringField('Base Path to use below:', [validators.DataRequired()])
import_path = StringField('Path(s) to import from:', [validators.DataRequired()])
storage_path = StringField('Path to store sorted images to:', [validators.DataRequired()])
recycle_bin_path = StringField('Path to temporarily store deleted images in:', [validators.DataRequired()])
metadata_path = StringField('Path to store metadata to:', [validators.DataRequired()])
auto_rotate = BooleanField('Automatically rotate jpegs based on exif', [validators.AnyOf([True, False])])
default_refimg_model = SelectField( 'Default model to use for reference images', choices=[(c.id, c.name) for c in AIModel.query.order_by('id')] )
default_scan_model = SelectField( 'Default model to use for all scanned images', choices=[(c.id, c.name) for c in AIModel.query.order_by('id')] )
default_threshold = StringField('Face Distance threshold (below is a match):', [validators.DataRequired()])
face_size_limit = StringField('Minimum size of a face:', [validators.DataRequired()])
scheduled_import_scan = IntegerField('Days between forced scan of import path', [validators.DataRequired()])
scheduled_storage_scan = IntegerField('Days between forced scan of storage path', [validators.DataRequired()])
scheduled_bin_cleanup = IntegerField('Days between checking to clean Recycle Bin:', [validators.DataRequired()])
bin_cleanup_file_age = IntegerField('Age of files to clean out of the Recycle Bin', [validators.DataRequired()])
job_archive_age = IntegerField('Age of jobs to archive', [validators.DataRequired()])
submit = SubmitField('Save' )
################################################################################
# /settings -> show current settings
################################################################################
@app.route("/settings", methods=["GET", "POST"])
@login_required
def settings():
form = SettingsForm(request.form)
page_title='Settings'
HELP={}
HELP['base_path']="Optional: if the paths below are absolute, then this field is ignored. If they are relative, then this field is prepended"
HELP['import_path']="Path(s) to import files from. If starting with /, then used literally, otherwise base path is prepended"
HELP['storage_path']="Path(s) to store sorted files to. If starting with /, then used literally, otherwise base path is prepended"
HELP['recycle_bin_path']="Path where deleted files are moved to. If starting with /, then used literally, otherwise base path is prepended"
HELP['metadata_path']="Path where metadata (overrides) are stored. If starting with /, then used literally, otherwise base path is prepended"
HELP['auto_rotate']="Automatically rotate jpegs based on exif to orient them so that AI matching will work. NOTE: this actually changes/rewrites the file - as it is a simple rotate, it is down without losing quality/content"
HELP['default_refimg_model']="Default face recognition model used for reference images - cnn is slower/more accurate, hog is faster/less accurate - we scan (small) refimg once, so cnn is okay"
HELP['default_scan_model']="Default face recognition model used for scanned images - cnn is slower/more accurate, hog is faster/less accurate - we scan (large) scanned images lots, so cnn NEEDS gpu/mem"
HELP['default_threshold']="The distance below which a face is considered a match. The default is usually 0.6, we are trying for about 0.55 with kids. YMMV"
HELP['face_size_limit']="The minimum size of a AI detected face, anything below this is so small that all matches fail, so we use this limit to hide them"
HELP['scheduled_import_scan']="The # of days between forced scans of the import path for new images"
HELP['scheduled_storage_scan']="The # of days between forced scans of the storage path for any file system changes outside of Photo Assistant"
HELP['scheduled_bin_cleanup']="The # of days between running a job to delete old files from the Recycle Bin"
HELP['bin_cleanup_file_age']="The # of days a file has to exist in the Recycle Bin before it can be really deleted"
HELP['job_archive_age']="The # of days a job has to exist for to be archived"
if request.method == 'POST' and form.validate():
try:
s = Settings.query.one()
if 'submit' in request.form:
st.SetMessage("Successfully Updated Settings" )
s.import_path = request.form['import_path']
s.storage_path = request.form['storage_path']
s.recycle_bin_path = request.form['recycle_bin_path']
s.metadata_path = request.form['metadata_path']
if 'auto_rotate' in request.form:
s.auto_rotate = True
else:
s.auto_rotate = False
s.default_refimg_model = request.form['default_refimg_model']
s.default_scan_model = request.form['default_scan_model']
s.default_threshold = request.form['default_threshold']
s.face_size_limit = request.form['face_size_limit']
s.scheduled_import_scan = request.form['scheduled_import_scan']
s.scheduled_storage_scan = request.form['scheduled_storage_scan']
s.scheduled_bin_cleanup = request.form['scheduled_bin_cleanup']
s.bin_cleanup_file_age = request.form['bin_cleanup_file_age']
s.job_archive_age = request.form['job_archive_age']
db.session.commit()
return redirect( url_for( 'settings' ) )
except SQLAlchemyError as e:
st.SetMessage( f"<b>Failed to modify Setting:</b>&nbsp;{e.orig}", "danger" )
return render_template("settings.html", form=form, page_title=page_title, HELP=HELP)
else:
form = SettingsForm( obj=Settings.query.first() )
return render_template("settings.html", form=form, page_title = page_title, HELP=HELP)
##############################################################################
# SettingsRBPath(): return modified array of paths (take each path in
# recycle_bin_path and add base_path if needed)
##############################################################################
def SettingsRBPath():
settings = Settings.query.first()
if settings == None:
print("Cannot create file data with no settings / recycle bin path is missing")
return
# path setting is an absolute path, just use it, otherwise prepend base_path first
if settings.recycle_bin_path[0] == '/':
path=settings.recycle_bin_path
else:
path=settings.base_path+settings.recycle_bin_path
return path
##############################################################################
# SettingsSPath(): return modified array of paths (take each path in
# storage_path and add base_path if needed)
##############################################################################
def SettingsSPath():
paths=[]
settings = Settings.query.first()
if settings == None:
print("Cannot create file data with no settings / storage path is missing")
return
if settings.storage_path[0] == '/':
path=settings.storage_path
else:
path=settings.base_path+settings.storage_path
return path
##############################################################################
# SettingsIPath(): return modified array of paths (take each path in
# import_path and add base_path if needed)
##############################################################################
def SettingsIPath():
paths=[]
settings = Settings.query.first()
if settings == None:
print ("Cannot create file data with no settings / import path is missing")
return
if settings.import_path[0] == '/':
path=settings.import_path
else:
path=settings.base_path+settings.import_path
return path
##############################################################################
# SettingsMPath(): return path to actual metadata path from settings
##############################################################################
def SettingsMPath():
settings = Settings.query.first()
if not settings or settings.metadata_path == "":
print ("WARNING: no Settings for metadata path")
return
p=settings.metadata_path
if p[0] == '/':
return p
else:
return settings.base_path+p