From 264144ebcaa3c1c44e9913a3cb4790d94c71a13e Mon Sep 17 00:00:00 2001
From: Jens Luedicke
Date: Tue, 1 Jul 2025 11:22:56 +0200
Subject: [PATCH 01/16] Prepare for fly.io
---
.dockerignore | 36 ++++++++++++++++++++++++++++++++++++
Dockerfile | 36 ++++++++++++++++++++++++++++++++++++
app.py | 3 ++-
fly.toml | 27 +++++++++++++++++++++++++++
4 files changed, 101 insertions(+), 1 deletion(-)
create mode 100644 .dockerignore
create mode 100644 Dockerfile
create mode 100644 fly.toml
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..249982c
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,36 @@
+.git
+.gitignore
+README.md
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.DS_Store
+__pycache__
+*.pyc
+*.pyo
+*.pyd
+.Python
+env
+pip-log.txt
+pip-delete-this-directory.txt
+.tox
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.log
+.venv
+.mypy_cache
+.pytest_cache
+.hypothesis
+fly.toml
+timetrack.db
+*.db-journal
+tests/
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..69677e0
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,36 @@
+FROM python:3.9-slim
+
+# Set working directory
+WORKDIR /app
+
+# Set environment variables
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ FLASK_APP=app.py
+
+# Install system dependencies
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ gcc \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy requirements file first for better caching
+COPY requirements.txt .
+
+# Install Python dependencies
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Copy the rest of the application
+COPY . .
+
+# Create the SQLite database directory with proper permissions
+RUN mkdir -p /app/instance && chmod 777 /app/instance
+
+# Expose the port the app runs on
+EXPOSE 5000
+
+# Run database migrations on startup
+RUN python -c "from app import app, db; app.app_context().push(); db.create_all()"
+
+# Command to run the application
+CMD ["flask", "run", "--host=0.0.0.0", "--port=5000"]
\ No newline at end of file
diff --git a/app.py b/app.py
index f55fc40..9aa6a82 100644
--- a/app.py
+++ b/app.py
@@ -1840,4 +1840,5 @@ def download_team_hours_export():
return redirect(url_for('team_hours'))
if __name__ == '__main__':
- app.run(debug=True, port=5050)
\ No newline at end of file
+ port = int(os.environ.get('PORT', 5000))
+ app.run(debug=False, host='0.0.0.0', port=port)
\ No newline at end of file
diff --git a/fly.toml b/fly.toml
new file mode 100644
index 0000000..2e73adb
--- /dev/null
+++ b/fly.toml
@@ -0,0 +1,27 @@
+app = "timetrack"
+primary_region = "iad"
+
+[build]
+
+[env]
+ FLASK_APP = "app.py"
+ FLASK_ENV = "production"
+
+[http_service]
+ internal_port = 5000
+ force_https = true
+ auto_stop_machines = true
+ auto_start_machines = true
+ min_machines_running = 0
+
+[[mounts]]
+ source = "timetrack_data"
+ destination = "/app/instance"
+
+[processes]
+ app = "flask run --host=0.0.0.0"
+
+[[vm]]
+ cpu_kind = "shared"
+ cpus = 1
+ memory_mb = 256
\ No newline at end of file
From f375e3a7323283c4687524a7d68108a7c9709501 Mon Sep 17 00:00:00 2001
From: "Fly.io"
Date: Tue, 1 Jul 2025 09:30:31 +0000
Subject: [PATCH 02/16] New files from Fly.io Launch
---
fly.toml | 27 +++++++++++----------------
1 file changed, 11 insertions(+), 16 deletions(-)
diff --git a/fly.toml b/fly.toml
index 2e73adb..53516d5 100644
--- a/fly.toml
+++ b/fly.toml
@@ -1,27 +1,22 @@
-app = "timetrack"
-primary_region = "iad"
+# fly.toml app configuration file generated for timetrack-2whuug on 2025-07-01T09:27:14Z
+#
+# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
+#
+
+app = 'timetrack-2whuug'
+primary_region = 'fra'
[build]
-[env]
- FLASK_APP = "app.py"
- FLASK_ENV = "production"
-
[http_service]
internal_port = 5000
force_https = true
- auto_stop_machines = true
+ auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 0
-
-[[mounts]]
- source = "timetrack_data"
- destination = "/app/instance"
-
-[processes]
- app = "flask run --host=0.0.0.0"
+ processes = ['app']
[[vm]]
- cpu_kind = "shared"
+ cpu_kind = 'shared'
cpus = 1
- memory_mb = 256
\ No newline at end of file
+ memory_mb = 256
From 2352d8ffd09314e9d28f4c10c66d1c1b9e046514 Mon Sep 17 00:00:00 2001
From: Jens Luedicke
Date: Tue, 1 Jul 2025 12:20:47 +0200
Subject: [PATCH 03/16] Add environment variables.
---
fly.toml | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/fly.toml b/fly.toml
index 53516d5..5113b67 100644
--- a/fly.toml
+++ b/fly.toml
@@ -16,6 +16,13 @@ primary_region = 'fra'
min_machines_running = 0
processes = ['app']
+[env]
+MAIL_SERVER = "smtp.ionos.de"
+MAIL_PORT = 587
+MAIL_USE_TLS = 1
+MAIL_USERNAME = jens@luedicke.cloud
+MAIL_DEFAULT_SENDER = "jens@luedicke.cloud"
+
[[vm]]
cpu_kind = 'shared'
cpus = 1
From 042a44bead3b927b3c831f8b2cf1d4c713dee553 Mon Sep 17 00:00:00 2001
From: Jens Luedicke
Date: Tue, 1 Jul 2025 12:28:56 +0200
Subject: [PATCH 04/16] Format fixes.
---
fly.toml | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/fly.toml b/fly.toml
index 5113b67..bd2900f 100644
--- a/fly.toml
+++ b/fly.toml
@@ -17,11 +17,11 @@ primary_region = 'fra'
processes = ['app']
[env]
-MAIL_SERVER = "smtp.ionos.de"
-MAIL_PORT = 587
-MAIL_USE_TLS = 1
-MAIL_USERNAME = jens@luedicke.cloud
-MAIL_DEFAULT_SENDER = "jens@luedicke.cloud"
+ MAIL_SERVER = "smtp.ionos.de"
+ MAIL_PORT = 587
+ MAIL_USE_TLS = 1
+ MAIL_USERNAME = "jens@luedicke.cloud"
+ MAIL_DEFAULT_SENDER = "jens@luedicke.cloud"
[[vm]]
cpu_kind = 'shared'
From 0693169839e683cf1f42faab2a76ff82be93d4d7 Mon Sep 17 00:00:00 2001
From: Jens Luedicke
Date: Tue, 1 Jul 2025 12:44:10 +0200
Subject: [PATCH 05/16] Make first user the admin.
---
app.py | 28 +++++++++++++++++++++-------
1 file changed, 21 insertions(+), 7 deletions(-)
diff --git a/app.py b/app.py
index 9aa6a82..dff4740 100644
--- a/app.py
+++ b/app.py
@@ -262,19 +262,33 @@ def register():
if error is None:
try:
+ # Check if this is the first user account
+ is_first_user = User.query.count() == 0
+
new_user = User(username=username, email=email, is_verified=False)
new_user.set_password(password)
+ # Make first user an admin with full privileges
+ if is_first_user:
+ new_user.is_admin = True
+ new_user.role = Role.ADMIN
+ new_user.is_verified = True # Auto-verify first user
+
# Generate verification token
token = new_user.generate_verification_token()
db.session.add(new_user)
db.session.commit()
- # Send verification email
- verification_url = url_for('verify_email', token=token, _external=True)
- msg = Message('Verify your TimeTrack account', recipients=[email])
- msg.body = f'''Hello {username},
+ if is_first_user:
+ # First user gets admin privileges and is auto-verified
+ logger.info(f"First user account created: {username} with admin privileges")
+ flash('Welcome! You are the first user and have been granted administrator privileges. You can now log in.', 'success')
+ else:
+ # Send verification email for regular users
+ verification_url = url_for('verify_email', token=token, _external=True)
+ msg = Message('Verify your TimeTrack account', recipients=[email])
+ msg.body = f'''Hello {username},
Thank you for registering with TimeTrack. To complete your registration, please click on the link below:
@@ -287,10 +301,10 @@ If you did not register for TimeTrack, please ignore this email.
Best regards,
The TimeTrack Team
'''
- mail.send(msg)
- logger.info(f"Verification email sent to {email}")
+ mail.send(msg)
+ logger.info(f"Verification email sent to {email}")
+ flash('Registration initiated! Please check your email to verify your account.', 'success')
- flash('Registration initiated! Please check your email to verify your account.', 'success')
return redirect(url_for('login'))
except Exception as e:
db.session.rollback()
From 3cf2f381c16fbdb8fc7ca325f471cfee9c00375f Mon Sep 17 00:00:00 2001
From: Jens Luedicke
Date: Tue, 1 Jul 2025 12:57:29 +0200
Subject: [PATCH 06/16] Store sqlite db on data volume.
---
app.py | 458 +++++++++++++++++++++++++++----------------------------
fly.toml | 5 +
2 files changed, 234 insertions(+), 229 deletions(-)
diff --git a/app.py b/app.py
index dff4740..d9e6cd2 100644
--- a/app.py
+++ b/app.py
@@ -20,7 +20,7 @@ logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
app = Flask(__name__)
-app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///timetrack.db'
+app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///data/timetrack.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev_key_for_timetrack')
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # Session lasts for 7 days
@@ -101,11 +101,11 @@ def role_required(min_role):
def decorated_function(*args, **kwargs):
if g.user is None:
return redirect(url_for('login', next=request.url))
-
+
# Admin always has access
if g.user.is_admin:
return f(*args, **kwargs)
-
+
# Check role hierarchy
role_hierarchy = {
Role.TEAM_MEMBER: 1,
@@ -113,11 +113,11 @@ def role_required(min_role):
Role.SUPERVISOR: 3,
Role.ADMIN: 4
}
-
+
if role_hierarchy.get(g.user.role, 0) < role_hierarchy.get(min_role, 0):
flash('You do not have sufficient permissions to access this page.', 'error')
return redirect(url_for('home'))
-
+
return f(*args, **kwargs)
return decorated_function
return role_decorator
@@ -141,10 +141,10 @@ def home():
if g.user:
# Get active entry (no departure time)
active_entry = TimeEntry.query.filter_by(
- user_id=g.user.id,
+ user_id=g.user.id,
departure_time=None
).first()
-
+
# Get today's completed entries for history
today = datetime.now().date()
history = TimeEntry.query.filter(
@@ -153,16 +153,16 @@ def home():
TimeEntry.arrival_time >= datetime.combine(today, time.min),
TimeEntry.arrival_time <= datetime.combine(today, time.max)
).order_by(TimeEntry.arrival_time.desc()).all()
-
+
# Get available projects for this user
available_projects = []
all_projects = Project.query.filter_by(is_active=True).all()
for project in all_projects:
if project.is_user_allowed(g.user):
available_projects.append(project)
-
- return render_template('index.html', title='Home',
- active_entry=active_entry,
+
+ return render_template('index.html', title='Home',
+ active_entry=active_entry,
history=history,
available_projects=available_projects)
else:
@@ -173,9 +173,9 @@ def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
-
+
user = User.query.filter_by(username=username).first()
-
+
if user:
# Fix role if it's a string or None
if isinstance(user.role, str) or user.role is None:
@@ -190,21 +190,21 @@ def login():
'Administrator': Role.ADMIN,
'ADMIN': Role.ADMIN
}
-
+
if isinstance(user.role, str):
user.role = role_mapping.get(user.role, Role.TEAM_MEMBER)
else:
user.role = Role.ADMIN if user.is_admin else Role.TEAM_MEMBER
-
+
db.session.commit()
-
+
# Now proceed with password check
if user.check_password(password):
# Check if user is blocked
if user.is_blocked:
flash('Your account has been disabled. Please contact an administrator.', 'error')
return render_template('login.html')
-
+
# Check if 2FA is enabled
if user.two_factor_enabled:
# Store user ID for 2FA verification
@@ -215,12 +215,12 @@ def login():
session['user_id'] = user.id
session['username'] = user.username
session['is_admin'] = user.is_admin
-
+
flash('Login successful!', 'success')
return redirect(url_for('home'))
-
+
flash('Invalid username or password', 'error')
-
+
return render_template('login.html', title='Login')
@app.route('/logout')
@@ -234,17 +234,17 @@ def register():
# Check if registration is enabled
reg_setting = SystemSettings.query.filter_by(key='registration_enabled').first()
registration_enabled = reg_setting and reg_setting.value == 'true'
-
+
if not registration_enabled:
flash('Registration is currently disabled by the administrator.', 'error')
return redirect(url_for('login'))
-
+
if request.method == 'POST':
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')
-
+
# Validate input
error = None
if not username:
@@ -259,27 +259,27 @@ def register():
error = 'Username already exists'
elif User.query.filter_by(email=email).first():
error = 'Email already registered'
-
+
if error is None:
try:
# Check if this is the first user account
is_first_user = User.query.count() == 0
-
+
new_user = User(username=username, email=email, is_verified=False)
new_user.set_password(password)
-
+
# Make first user an admin with full privileges
if is_first_user:
new_user.is_admin = True
new_user.role = Role.ADMIN
new_user.is_verified = True # Auto-verify first user
-
+
# Generate verification token
token = new_user.generate_verification_token()
-
+
db.session.add(new_user)
db.session.commit()
-
+
if is_first_user:
# First user gets admin privileges and is auto-verified
logger.info(f"First user account created: {username} with admin privileges")
@@ -304,31 +304,31 @@ The TimeTrack Team
mail.send(msg)
logger.info(f"Verification email sent to {email}")
flash('Registration initiated! Please check your email to verify your account.', 'success')
-
+
return redirect(url_for('login'))
except Exception as e:
db.session.rollback()
logger.error(f"Error during registration: {str(e)}")
error = f"An error occurred during registration: {str(e)}"
-
+
flash(error, 'error')
-
+
return render_template('register.html', title='Register')
@app.route('/verify_email/')
def verify_email(token):
user = User.query.filter_by(verification_token=token).first()
-
+
if not user:
flash('Invalid or expired verification link.', 'error')
return redirect(url_for('login'))
-
+
if user.verify_token(token):
db.session.commit()
flash('Email verified successfully! You can now log in.', 'success')
else:
flash('Invalid or expired verification link.', 'error')
-
+
return redirect(url_for('login'))
@app.route('/dashboard')
@@ -336,7 +336,7 @@ def verify_email(token):
def dashboard():
# Get dashboard data based on user role
dashboard_data = {}
-
+
if g.user.is_admin or g.user.role == Role.ADMIN:
# Admin sees everything
dashboard_data.update({
@@ -346,7 +346,7 @@ def dashboard():
'unverified_users': User.query.filter_by(is_verified=False).count(),
'recent_registrations': User.query.order_by(User.id.desc()).limit(5).all()
})
-
+
if g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] or g.user.is_admin:
# Team leaders and supervisors see team-related data
if g.user.team_id or g.user.is_admin:
@@ -358,13 +358,13 @@ def dashboard():
# Team leaders/supervisors see their own team
teams = [Team.query.get(g.user.team_id)] if g.user.team_id else []
team_members = User.query.filter_by(team_id=g.user.team_id).all() if g.user.team_id else []
-
+
dashboard_data.update({
'teams': teams,
'team_members': team_members,
'team_member_count': len(team_members)
})
-
+
# Get recent time entries for the user's oversight
if g.user.is_admin:
# Admin sees all recent entries
@@ -375,9 +375,9 @@ def dashboard():
recent_entries = TimeEntry.query.filter(TimeEntry.user_id.in_(team_user_ids)).order_by(TimeEntry.arrival_time.desc()).limit(10).all()
else:
recent_entries = []
-
+
dashboard_data['recent_entries'] = recent_entries
-
+
return render_template('dashboard.html', title='Dashboard', **dashboard_data)
# Redirect old admin dashboard URL to new dashboard
@@ -397,11 +397,11 @@ def create_user():
password = request.form.get('password')
is_admin = 'is_admin' in request.form
auto_verify = 'auto_verify' in request.form
-
+
# Get role and team
role_name = request.form.get('role')
team_id = request.form.get('team_id')
-
+
# Validate input
error = None
if not username:
@@ -414,25 +414,25 @@ def create_user():
error = 'Username already exists'
elif User.query.filter_by(email=email).first():
error = 'Email already registered'
-
+
if error is None:
# Convert role string to enum
try:
role = Role[role_name] if role_name else Role.TEAM_MEMBER
except KeyError:
role = Role.TEAM_MEMBER
-
+
# Create new user with role and team
new_user = User(
- username=username,
- email=email,
- is_admin=is_admin,
+ username=username,
+ email=email,
+ is_admin=is_admin,
is_verified=auto_verify,
role=role,
team_id=team_id if team_id else None
)
new_user.set_password(password)
-
+
if not auto_verify:
# Generate verification token and send email
token = new_user.generate_verification_token()
@@ -450,39 +450,39 @@ Best regards,
The TimeTrack Team
'''
mail.send(msg)
-
+
db.session.add(new_user)
db.session.commit()
-
+
if auto_verify:
flash(f'User {username} created and automatically verified!', 'success')
else:
flash(f'User {username} created! Verification email sent.', 'success')
return redirect(url_for('admin_users'))
-
+
flash(error, 'error')
-
+
# Get all teams for the form
teams = Team.query.all()
roles = [role for role in Role]
-
+
return render_template('create_user.html', title='Create User', teams=teams, roles=roles)
@app.route('/admin/users/edit/', methods=['GET', 'POST'])
@admin_required
def edit_user(user_id):
user = User.query.get_or_404(user_id)
-
+
if request.method == 'POST':
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
is_admin = 'is_admin' in request.form
-
+
# Get role and team
role_name = request.form.get('role')
team_id = request.form.get('team_id')
-
+
# Validate input
error = None
if not username:
@@ -493,50 +493,50 @@ def edit_user(user_id):
error = 'Username already exists'
elif email != user.email and User.query.filter_by(email=email).first():
error = 'Email already registered'
-
+
if error is None:
user.username = username
user.email = email
user.is_admin = is_admin
-
+
# Convert role string to enum
try:
user.role = Role[role_name] if role_name else Role.TEAM_MEMBER
except KeyError:
user.role = Role.TEAM_MEMBER
-
+
user.team_id = team_id if team_id else None
-
+
if password:
user.set_password(password)
-
+
db.session.commit()
-
+
flash(f'User {username} updated successfully!', 'success')
return redirect(url_for('admin_users'))
-
+
flash(error, 'error')
-
+
# Get all teams for the form
teams = Team.query.all()
roles = [role for role in Role]
-
+
return render_template('edit_user.html', title='Edit User', user=user, teams=teams, roles=roles)
@app.route('/admin/users/delete/', methods=['POST'])
@admin_required
def delete_user(user_id):
user = User.query.get_or_404(user_id)
-
+
# Prevent deleting yourself
if user.id == session.get('user_id'):
flash('You cannot delete your own account', 'error')
return redirect(url_for('admin_users'))
-
+
username = user.username
db.session.delete(user)
db.session.commit()
-
+
flash(f'User {username} deleted successfully', 'success')
return redirect(url_for('admin_users'))
@@ -544,20 +544,20 @@ def delete_user(user_id):
@login_required
def profile():
user = User.query.get(session['user_id'])
-
+
if request.method == 'POST':
email = request.form.get('email')
current_password = request.form.get('current_password')
new_password = request.form.get('new_password')
confirm_password = request.form.get('confirm_password')
-
+
# Validate input
error = None
if not email:
error = 'Email is required'
elif email != user.email and User.query.filter_by(email=email).first():
error = 'Email already registered'
-
+
# Password change validation
if new_password:
if not current_password:
@@ -566,19 +566,19 @@ def profile():
error = 'Current password is incorrect'
elif new_password != confirm_password:
error = 'New passwords do not match'
-
+
if error is None:
user.email = email
-
+
if new_password:
user.set_password(new_password)
-
+
db.session.commit()
flash('Profile updated successfully!', 'success')
return redirect(url_for('profile'))
-
+
flash(error, 'error')
-
+
return render_template('profile.html', title='My Profile', user=user)
@app.route('/2fa/setup', methods=['GET', 'POST'])
@@ -587,11 +587,11 @@ def setup_2fa():
if request.method == 'POST':
# Verify the TOTP code before enabling 2FA
totp_code = request.form.get('totp_code')
-
+
if not totp_code:
flash('Please enter the verification code from your authenticator app.', 'error')
return redirect(url_for('setup_2fa'))
-
+
try:
if g.user.verify_2fa_token(totp_code, allow_setup=True):
g.user.two_factor_enabled = True
@@ -605,35 +605,35 @@ def setup_2fa():
logger.error(f"2FA setup error: {str(e)}")
flash('An error occurred during 2FA setup. Please try again.', 'error')
return redirect(url_for('setup_2fa'))
-
+
# GET request - show setup page
if g.user.two_factor_enabled:
flash('Two-factor authentication is already enabled.', 'info')
return redirect(url_for('profile'))
-
+
# Generate secret if not exists
if not g.user.two_factor_secret:
g.user.generate_2fa_secret()
db.session.commit()
-
+
# Generate QR code
import qrcode
import io
import base64
-
+
qr_uri = g.user.get_2fa_uri()
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(qr_uri)
qr.make(fit=True)
-
+
# Create QR code image
qr_img = qr.make_image(fill_color="black", back_color="white")
img_buffer = io.BytesIO()
qr_img.save(img_buffer, format='PNG')
img_buffer.seek(0)
qr_code_b64 = base64.b64encode(img_buffer.getvalue()).decode()
-
- return render_template('setup_2fa.html',
+
+ return render_template('setup_2fa.html',
title='Setup Two-Factor Authentication',
secret=g.user.two_factor_secret,
qr_code=qr_code_b64)
@@ -642,15 +642,15 @@ def setup_2fa():
@login_required
def disable_2fa():
password = request.form.get('password')
-
+
if not password or not g.user.check_password(password):
flash('Please enter your correct password to disable 2FA.', 'error')
return redirect(url_for('profile'))
-
+
g.user.two_factor_enabled = False
g.user.two_factor_secret = None
db.session.commit()
-
+
flash('Two-factor authentication has been disabled.', 'success')
return redirect(url_for('profile'))
@@ -660,27 +660,27 @@ def verify_2fa():
user_id = session.get('2fa_user_id')
if not user_id:
return redirect(url_for('login'))
-
+
user = User.query.get(user_id)
if not user or not user.two_factor_enabled:
session.pop('2fa_user_id', None)
return redirect(url_for('login'))
-
+
if request.method == 'POST':
totp_code = request.form.get('totp_code')
-
+
if user.verify_2fa_token(totp_code):
# Complete login process
session.pop('2fa_user_id', None)
session['user_id'] = user.id
session['username'] = user.username
session['is_admin'] = user.is_admin
-
+
flash('Login successful!', 'success')
return redirect(url_for('home'))
else:
flash('Invalid verification code. Please try again.', 'error')
-
+
return render_template('verify_2fa.html', title='Two-Factor Authentication')
@app.route('/about')
@@ -706,16 +706,16 @@ def arrive():
# Get project and notes from request
project_id = request.json.get('project_id') if request.json else None
notes = request.json.get('notes') if request.json else None
-
+
# Validate project access if project is specified
if project_id:
project = Project.query.get(project_id)
if not project or not project.is_user_allowed(g.user):
return jsonify({'error': 'Invalid or unauthorized project'}), 403
-
+
# Create a new time entry with arrival time for the current user
new_entry = TimeEntry(
- user_id=g.user.id,
+ user_id=g.user.id,
arrival_time=datetime.now(),
project_id=int(project_id) if project_id else None,
notes=notes
@@ -838,11 +838,11 @@ def create_tables():
# Check if we need to add new columns
from sqlalchemy import inspect
inspector = inspect(db.engine)
-
+
# Check if user table exists
if 'user' in inspector.get_table_names():
columns = [column['name'] for column in inspector.get_columns('user')]
-
+
# Check for verification columns
if 'is_verified' not in columns or 'verification_token' not in columns or 'token_expiry' not in columns:
logger.warning("Database schema is outdated. Please run migrate_db.py to update it.")
@@ -902,19 +902,19 @@ def update_entry(entry_id):
def team_hours():
# Get the current user's team
team = Team.query.get(g.user.team_id)
-
+
if not team:
flash('You are not assigned to any team.', 'error')
return redirect(url_for('home'))
-
+
# Get date range from query parameters or use current week as default
today = datetime.now().date()
start_of_week = today - timedelta(days=today.weekday())
end_of_week = start_of_week + timedelta(days=6)
-
+
start_date_str = request.args.get('start_date', start_of_week.strftime('%Y-%m-%d'))
end_date_str = request.args.get('end_date', end_of_week.strftime('%Y-%m-%d'))
-
+
try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
@@ -922,14 +922,14 @@ def team_hours():
flash('Invalid date format. Using current week instead.', 'warning')
start_date = start_of_week
end_date = end_of_week
-
+
# Generate a list of dates in the range for the table header
date_range = []
current_date = start_date
while current_date <= end_date:
date_range.append(current_date)
current_date += timedelta(days=1)
-
+
return render_template(
'team_hours.html',
title=f'Team Hours',
@@ -943,10 +943,10 @@ def team_hours():
def history():
# Get project filter from query parameters
project_filter = request.args.get('project_id')
-
+
# Base query for user's time entries
query = TimeEntry.query.filter_by(user_id=g.user.id)
-
+
# Apply project filter if specified
if project_filter:
if project_filter == 'none':
@@ -960,10 +960,10 @@ def history():
except ValueError:
# Invalid project ID, ignore filter
pass
-
+
# Get filtered entries ordered by most recent first
all_entries = query.order_by(TimeEntry.arrival_time.desc()).all()
-
+
# Get available projects for the filter dropdown
available_projects = []
all_projects = Project.query.filter_by(is_active=True).all()
@@ -971,7 +971,7 @@ def history():
if project.is_user_allowed(g.user):
available_projects.append(project)
- return render_template('history.html', title='Time Entry History',
+ return render_template('history.html', title='Time Entry History',
entries=all_entries, available_projects=available_projects)
def calculate_work_duration(arrival_time, departure_time, total_break_duration):
@@ -1067,21 +1067,21 @@ def test():
@admin_required
def toggle_user_status(user_id):
user = User.query.get_or_404(user_id)
-
+
# Prevent blocking yourself
if user.id == session.get('user_id'):
flash('You cannot block your own account', 'error')
return redirect(url_for('admin_users'))
-
+
# Toggle the blocked status
user.is_blocked = not user.is_blocked
db.session.commit()
-
+
if user.is_blocked:
flash(f'User {user.username} has been blocked', 'success')
else:
flash(f'User {user.username} has been unblocked', 'success')
-
+
return redirect(url_for('admin_users'))
# Add this route to manage system settings
@@ -1091,19 +1091,19 @@ def admin_settings():
if request.method == 'POST':
# Update registration setting
registration_enabled = 'registration_enabled' in request.form
-
+
reg_setting = SystemSettings.query.filter_by(key='registration_enabled').first()
if reg_setting:
reg_setting.value = 'true' if registration_enabled else 'false'
db.session.commit()
flash('System settings updated successfully!', 'success')
-
+
# Get current settings
settings = {}
for setting in SystemSettings.query.all():
if setting.key == 'registration_enabled':
settings['registration_enabled'] = setting.value == 'true'
-
+
return render_template('admin_settings.html', title='System Settings', settings=settings)
# Add these routes for team management
@@ -1119,68 +1119,68 @@ def create_team():
if request.method == 'POST':
name = request.form.get('name')
description = request.form.get('description')
-
+
# Validate input
error = None
if not name:
error = 'Team name is required'
elif Team.query.filter_by(name=name).first():
error = 'Team name already exists'
-
+
if error is None:
new_team = Team(name=name, description=description)
db.session.add(new_team)
db.session.commit()
-
+
flash(f'Team "{name}" created successfully!', 'success')
return redirect(url_for('admin_teams'))
-
+
flash(error, 'error')
-
+
return render_template('create_team.html', title='Create Team')
@app.route('/admin/teams/edit/', methods=['GET', 'POST'])
@admin_required
def edit_team(team_id):
team = Team.query.get_or_404(team_id)
-
+
if request.method == 'POST':
name = request.form.get('name')
description = request.form.get('description')
-
+
# Validate input
error = None
if not name:
error = 'Team name is required'
elif name != team.name and Team.query.filter_by(name=name).first():
error = 'Team name already exists'
-
+
if error is None:
team.name = name
team.description = description
db.session.commit()
-
+
flash(f'Team "{name}" updated successfully!', 'success')
return redirect(url_for('admin_teams'))
-
+
flash(error, 'error')
-
+
return render_template('edit_team.html', title='Edit Team', team=team)
@app.route('/admin/teams/delete/', methods=['POST'])
@admin_required
def delete_team(team_id):
team = Team.query.get_or_404(team_id)
-
+
# Check if team has members
if team.users:
flash('Cannot delete team with members. Remove all members first.', 'error')
return redirect(url_for('admin_teams'))
-
+
team_name = team.name
db.session.delete(team)
db.session.commit()
-
+
flash(f'Team "{team_name}" deleted successfully!', 'success')
return redirect(url_for('admin_teams'))
@@ -1188,22 +1188,22 @@ def delete_team(team_id):
@admin_required
def manage_team(team_id):
team = Team.query.get_or_404(team_id)
-
+
if request.method == 'POST':
action = request.form.get('action')
-
+
if action == 'update_team':
# Update team details
name = request.form.get('name')
description = request.form.get('description')
-
+
# Validate input
error = None
if not name:
error = 'Team name is required'
elif name != team.name and Team.query.filter_by(name=name).first():
error = 'Team name already exists'
-
+
if error is None:
team.name = name
team.description = description
@@ -1211,7 +1211,7 @@ def manage_team(team_id):
flash(f'Team "{name}" updated successfully!', 'success')
else:
flash(error, 'error')
-
+
elif action == 'add_member':
# Add user to team
user_id = request.form.get('user_id')
@@ -1225,7 +1225,7 @@ def manage_team(team_id):
flash('User not found', 'error')
else:
flash('No user selected', 'error')
-
+
elif action == 'remove_member':
# Remove user from team
user_id = request.form.get('user_id')
@@ -1239,19 +1239,19 @@ def manage_team(team_id):
flash('User not found or not in this team', 'error')
else:
flash('No user selected', 'error')
-
+
# Get team members
team_members = User.query.filter_by(team_id=team.id).all()
-
+
# Get users not in this team for the add member form
available_users = User.query.filter(
(User.team_id != team.id) | (User.team_id == None)
).all()
-
+
return render_template(
- 'manage_team.html',
- title=f'Manage Team: {team.name}',
- team=team,
+ 'manage_team.html',
+ title=f'Manage Team: {team.name}',
+ team=team,
team_members=team_members,
available_users=available_users
)
@@ -1273,7 +1273,7 @@ def create_project():
team_id = request.form.get('team_id') or None
start_date_str = request.form.get('start_date')
end_date_str = request.form.get('end_date')
-
+
# Validate input
error = None
if not name:
@@ -1282,7 +1282,7 @@ def create_project():
error = 'Project code is required'
elif Project.query.filter_by(code=code).first():
error = 'Project code already exists'
-
+
# Parse dates
start_date = None
end_date = None
@@ -1291,16 +1291,16 @@ def create_project():
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
except ValueError:
error = 'Invalid start date format'
-
+
if end_date_str:
try:
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
except ValueError:
error = 'Invalid end date format'
-
+
if start_date and end_date and start_date > end_date:
error = 'Start date cannot be after end date'
-
+
if error is None:
project = Project(
name=name,
@@ -1317,7 +1317,7 @@ def create_project():
return redirect(url_for('admin_projects'))
else:
flash(error, 'error')
-
+
# Get available teams for the form
teams = Team.query.order_by(Team.name).all()
return render_template('create_project.html', title='Create Project', teams=teams)
@@ -1326,7 +1326,7 @@ def create_project():
@role_required(Role.SUPERVISOR)
def edit_project(project_id):
project = Project.query.get_or_404(project_id)
-
+
if request.method == 'POST':
name = request.form.get('name')
description = request.form.get('description')
@@ -1335,7 +1335,7 @@ def edit_project(project_id):
is_active = request.form.get('is_active') == 'on'
start_date_str = request.form.get('start_date')
end_date_str = request.form.get('end_date')
-
+
# Validate input
error = None
if not name:
@@ -1344,7 +1344,7 @@ def edit_project(project_id):
error = 'Project code is required'
elif code != project.code and Project.query.filter_by(code=code).first():
error = 'Project code already exists'
-
+
# Parse dates
start_date = None
end_date = None
@@ -1353,16 +1353,16 @@ def edit_project(project_id):
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
except ValueError:
error = 'Invalid start date format'
-
+
if end_date_str:
try:
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
except ValueError:
error = 'Invalid end date format'
-
+
if start_date and end_date and start_date > end_date:
error = 'Start date cannot be after end date'
-
+
if error is None:
project.name = name
project.description = description
@@ -1376,7 +1376,7 @@ def edit_project(project_id):
return redirect(url_for('admin_projects'))
else:
flash(error, 'error')
-
+
# Get available teams for the form
teams = Team.query.order_by(Team.name).all()
return render_template('edit_project.html', title='Edit Project', project=project, teams=teams)
@@ -1385,10 +1385,10 @@ def edit_project(project_id):
@role_required(Role.ADMIN) # Only admins can delete projects
def delete_project(project_id):
project = Project.query.get_or_404(project_id)
-
+
# Check if there are time entries associated with this project
time_entries_count = TimeEntry.query.filter_by(project_id=project_id).count()
-
+
if time_entries_count > 0:
flash(f'Cannot delete project "{project.name}" - it has {time_entries_count} time entries associated with it. Deactivate the project instead.', 'error')
else:
@@ -1396,7 +1396,7 @@ def delete_project(project_id):
db.session.delete(project)
db.session.commit()
flash(f'Project "{project_name}" deleted successfully!', 'success')
-
+
return redirect(url_for('admin_projects'))
@app.route('/api/team/hours_data', methods=['GET'])
@@ -1405,22 +1405,22 @@ def delete_project(project_id):
def team_hours_data():
# Get the current user's team
team = Team.query.get(g.user.team_id)
-
+
if not team:
return jsonify({
'success': False,
'message': 'You are not assigned to any team.'
}), 400
-
+
# Get date range from query parameters or use current week as default
today = datetime.now().date()
start_of_week = today - timedelta(days=today.weekday())
end_of_week = start_of_week + timedelta(days=6)
-
+
start_date_str = request.args.get('start_date', start_of_week.strftime('%Y-%m-%d'))
end_date_str = request.args.get('end_date', end_of_week.strftime('%Y-%m-%d'))
include_self = request.args.get('include_self', 'false') == 'true'
-
+
try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
@@ -1429,46 +1429,46 @@ def team_hours_data():
'success': False,
'message': 'Invalid date format.'
}), 400
-
+
# Get all team members
team_members = User.query.filter_by(team_id=team.id).all()
-
+
# Prepare data structure for team members' hours
team_data = []
-
+
for member in team_members:
# Skip if the member is the current user (team leader) and include_self is False
if member.id == g.user.id and not include_self:
continue
-
+
# Get time entries for this member in the date range
entries = TimeEntry.query.filter(
TimeEntry.user_id == member.id,
TimeEntry.arrival_time >= datetime.combine(start_date, time.min),
TimeEntry.arrival_time <= datetime.combine(end_date, time.max)
).order_by(TimeEntry.arrival_time).all()
-
+
# Calculate daily and total hours
daily_hours = {}
total_seconds = 0
-
+
for entry in entries:
if entry.duration: # Only count completed entries
entry_date = entry.arrival_time.date()
date_str = entry_date.strftime('%Y-%m-%d')
-
+
if date_str not in daily_hours:
daily_hours[date_str] = 0
-
+
daily_hours[date_str] += entry.duration
total_seconds += entry.duration
-
+
# Convert seconds to hours for display
for date_str in daily_hours:
daily_hours[date_str] = round(daily_hours[date_str] / 3600, 2) # Convert to hours
-
+
total_hours = round(total_seconds / 3600, 2) # Convert to hours
-
+
# Format entries for JSON response
formatted_entries = []
for entry in entries:
@@ -1479,7 +1479,7 @@ def team_hours_data():
'duration': entry.duration,
'total_break_duration': entry.total_break_duration
})
-
+
# Add member data to team data
team_data.append({
'user': {
@@ -1491,14 +1491,14 @@ def team_hours_data():
'total_hours': total_hours,
'entries': formatted_entries
})
-
+
# Generate a list of dates in the range for the table header
date_range = []
current_date = start_date
while current_date <= end_date:
date_range.append(current_date.strftime('%Y-%m-%d'))
current_date += timedelta(days=1)
-
+
return jsonify({
'success': True,
'team': {
@@ -1519,7 +1519,7 @@ def export():
def get_date_range(period, start_date_str=None, end_date_str=None):
"""Get start and end date based on period or custom date range."""
today = datetime.now().date()
-
+
if period:
if period == 'today':
return today, today
@@ -1576,7 +1576,7 @@ def export_to_csv(data, filename):
writer = csv.DictWriter(output, fieldnames=data[0].keys())
writer.writeheader()
writer.writerows(data)
-
+
return Response(
output.getvalue(),
mimetype='text/csv',
@@ -1587,18 +1587,18 @@ def export_to_excel(data, filename):
"""Export data to Excel format with formatting."""
df = pd.DataFrame(data)
output = io.BytesIO()
-
+
with pd.ExcelWriter(output, engine='xlsxwriter') as writer:
df.to_excel(writer, sheet_name='TimeTrack Data', index=False)
-
+
# Auto-adjust columns' width
worksheet = writer.sheets['TimeTrack Data']
for i, col in enumerate(df.columns):
column_width = max(df[col].astype(str).map(len).max(), len(col)) + 2
worksheet.set_column(i, i, column_width)
-
+
output.seek(0)
-
+
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
@@ -1609,11 +1609,11 @@ def export_to_excel(data, filename):
def prepare_team_hours_export_data(team, team_data, date_range):
"""Prepare team hours data for export."""
export_data = []
-
+
for member_data in team_data:
user = member_data['user']
daily_hours = member_data['daily_hours']
-
+
# Create base row with member info
row = {
'Team': team['name'],
@@ -1621,26 +1621,26 @@ def prepare_team_hours_export_data(team, team_data, date_range):
'Email': user['email'],
'Total Hours': member_data['total_hours']
}
-
+
# Add daily hours columns
for date_str in date_range:
formatted_date = datetime.strptime(date_str, '%Y-%m-%d').strftime('%m/%d/%Y')
row[formatted_date] = daily_hours.get(date_str, 0.0)
-
+
export_data.append(row)
-
+
return export_data
def export_team_hours_to_csv(data, filename):
"""Export team hours data to CSV format."""
if not data:
return None
-
+
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=data[0].keys())
writer.writeheader()
writer.writerows(data)
-
+
return Response(
output.getvalue(),
mimetype='text/csv',
@@ -1651,17 +1651,17 @@ def export_team_hours_to_excel(data, filename, team_name):
"""Export team hours data to Excel format with formatting."""
if not data:
return None
-
+
df = pd.DataFrame(data)
output = io.BytesIO()
-
+
with pd.ExcelWriter(output, engine='xlsxwriter') as writer:
df.to_excel(writer, sheet_name=f'{team_name} Hours', index=False)
-
+
# Get the workbook and worksheet objects
workbook = writer.book
worksheet = writer.sheets[f'{team_name} Hours']
-
+
# Create formats
header_format = workbook.add_format({
'bold': True,
@@ -1671,17 +1671,17 @@ def export_team_hours_to_excel(data, filename, team_name):
'font_color': 'white',
'border': 1
})
-
+
# Auto-adjust columns' width and apply formatting
for i, col in enumerate(df.columns):
column_width = max(df[col].astype(str).map(len).max(), len(col)) + 2
worksheet.set_column(i, i, column_width)
-
+
# Apply header formatting
worksheet.write(0, i, col, header_format)
-
+
output.seek(0)
-
+
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
@@ -1694,34 +1694,34 @@ def download_export():
"""Handle export download requests."""
export_format = request.args.get('format', 'csv')
period = request.args.get('period')
-
+
try:
start_date, end_date = get_date_range(
- period,
- request.args.get('start_date'),
+ period,
+ request.args.get('start_date'),
request.args.get('end_date')
)
except ValueError:
flash('Invalid date format. Please use YYYY-MM-DD format.')
return redirect(url_for('export'))
-
+
# Query entries within the date range
start_datetime = datetime.combine(start_date, time.min)
end_datetime = datetime.combine(end_date, time.max)
-
+
entries = TimeEntry.query.filter(
TimeEntry.arrival_time >= start_datetime,
TimeEntry.arrival_time <= end_datetime
).order_by(TimeEntry.arrival_time).all()
-
+
if not entries:
flash('No entries found for the selected date range.')
return redirect(url_for('export'))
-
+
# Prepare data and filename
data = prepare_export_data(entries)
filename = f"timetrack_export_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}"
-
+
# Export based on format
if export_format == 'csv':
return export_to_csv(data, filename)
@@ -1737,69 +1737,69 @@ def download_export():
def download_team_hours_export():
"""Handle team hours export download requests."""
export_format = request.args.get('format', 'csv')
-
+
# Get the current user's team
team = Team.query.get(g.user.team_id)
-
+
if not team:
flash('You are not assigned to any team.')
return redirect(url_for('team_hours'))
-
+
# Get date range from query parameters or use current week as default
today = datetime.now().date()
start_of_week = today - timedelta(days=today.weekday())
end_of_week = start_of_week + timedelta(days=6)
-
+
start_date_str = request.args.get('start_date', start_of_week.strftime('%Y-%m-%d'))
end_date_str = request.args.get('end_date', end_of_week.strftime('%Y-%m-%d'))
include_self = request.args.get('include_self', 'false') == 'true'
-
+
try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
except ValueError:
flash('Invalid date format.')
return redirect(url_for('team_hours'))
-
+
# Get all team members
team_members = User.query.filter_by(team_id=team.id).all()
-
+
# Prepare data structure for team members' hours
team_data = []
-
+
for member in team_members:
# Skip if the member is the current user (team leader) and include_self is False
if member.id == g.user.id and not include_self:
continue
-
+
# Get time entries for this member in the date range
entries = TimeEntry.query.filter(
TimeEntry.user_id == member.id,
TimeEntry.arrival_time >= datetime.combine(start_date, time.min),
TimeEntry.arrival_time <= datetime.combine(end_date, time.max)
).order_by(TimeEntry.arrival_time).all()
-
+
# Calculate daily and total hours
daily_hours = {}
total_seconds = 0
-
+
for entry in entries:
if entry.duration: # Only count completed entries
entry_date = entry.arrival_time.date()
date_str = entry_date.strftime('%Y-%m-%d')
-
+
if date_str not in daily_hours:
daily_hours[date_str] = 0
-
+
daily_hours[date_str] += entry.duration
total_seconds += entry.duration
-
+
# Convert seconds to hours for display
for date_str in daily_hours:
daily_hours[date_str] = round(daily_hours[date_str] / 3600, 2) # Convert to hours
-
+
total_hours = round(total_seconds / 3600, 2) # Convert to hours
-
+
# Add member data to team data
team_data.append({
'user': {
@@ -1810,30 +1810,30 @@ def download_team_hours_export():
'daily_hours': daily_hours,
'total_hours': total_hours
})
-
+
if not team_data:
flash('No team member data found for the selected date range.')
return redirect(url_for('team_hours'))
-
+
# Generate a list of dates in the range
date_range = []
current_date = start_date
while current_date <= end_date:
date_range.append(current_date.strftime('%Y-%m-%d'))
current_date += timedelta(days=1)
-
+
# Prepare data for export
team_info = {
'id': team.id,
'name': team.name,
'description': team.description
}
-
+
export_data = prepare_team_hours_export_data(team_info, team_data, date_range)
-
+
# Generate filename
filename = f"{team.name.replace(' ', '_')}_hours_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}"
-
+
# Export based on format
if export_format == 'csv':
response = export_team_hours_to_csv(export_data, filename)
@@ -1852,7 +1852,7 @@ def download_team_hours_export():
else:
flash('Invalid export format.')
return redirect(url_for('team_hours'))
-
+
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000))
app.run(debug=False, host='0.0.0.0', port=port)
\ No newline at end of file
diff --git a/fly.toml b/fly.toml
index bd2900f..a46b6e7 100644
--- a/fly.toml
+++ b/fly.toml
@@ -23,6 +23,11 @@ primary_region = 'fra'
MAIL_USERNAME = "jens@luedicke.cloud"
MAIL_DEFAULT_SENDER = "jens@luedicke.cloud"
+
+[mounts]
+ source = "timetrack_data"
+ destination = "/data"
+
[[vm]]
cpu_kind = 'shared'
cpus = 1
From ae0ca14b6fb37f87cc7db80761c56328b3dba4d4 Mon Sep 17 00:00:00 2001
From: Jens Luedicke
Date: Tue, 1 Jul 2025 13:02:45 +0200
Subject: [PATCH 07/16] Create /data folder in Dockerfile.
---
Dockerfile | 2 ++
1 file changed, 2 insertions(+)
diff --git a/Dockerfile b/Dockerfile
index 69677e0..5707408 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,6 +23,8 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the application
COPY . .
+RUN mkdir /data
+
# Create the SQLite database directory with proper permissions
RUN mkdir -p /app/instance && chmod 777 /app/instance
From 74c232f2273fb95dfd10ded2d45f6a683e4a570c Mon Sep 17 00:00:00 2001
From: Jens Luedicke
Date: Tue, 1 Jul 2025 13:10:50 +0200
Subject: [PATCH 08/16] Set proper permissions for data volume.
---
Dockerfile | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 5707408..fb37edb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,11 +23,12 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the application
COPY . .
-RUN mkdir /data
-
# Create the SQLite database directory with proper permissions
RUN mkdir -p /app/instance && chmod 777 /app/instance
+RUN mkdir /data
+RUN chmod 777 /data
+
# Expose the port the app runs on
EXPOSE 5000
From 807ebbd4fe826f743e473def5e32a9cc44a95544 Mon Sep 17 00:00:00 2001
From: Jens Luedicke
Date: Tue, 1 Jul 2025 13:15:51 +0200
Subject: [PATCH 09/16] Remove db init from Dockerfile.
---
Dockerfile | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index fb37edb..b844869 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -32,8 +32,7 @@ RUN chmod 777 /data
# Expose the port the app runs on
EXPOSE 5000
-# Run database migrations on startup
-RUN python -c "from app import app, db; app.app_context().push(); db.create_all()"
+# Database will be created at runtime when /data volume is mounted
# Command to run the application
CMD ["flask", "run", "--host=0.0.0.0", "--port=5000"]
\ No newline at end of file
From 09ff3e59cfe12002258cce762c5464453ae966cd Mon Sep 17 00:00:00 2001
From: Jens Luedicke
Date: Tue, 1 Jul 2025 14:11:20 +0200
Subject: [PATCH 10/16] Configure /data as a volume.
---
Dockerfile | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index b844869..59e295b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -26,8 +26,7 @@ COPY . .
# Create the SQLite database directory with proper permissions
RUN mkdir -p /app/instance && chmod 777 /app/instance
-RUN mkdir /data
-RUN chmod 777 /data
+VOLUME /data
# Expose the port the app runs on
EXPOSE 5000
From 57c7a057091953a1d75b9fe6062571cffec4cdc7 Mon Sep 17 00:00:00 2001
From: Jens Luedicke
Date: Tue, 1 Jul 2025 14:30:27 +0200
Subject: [PATCH 11/16] Fix path to sqlite db.
---
app.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app.py b/app.py
index d9e6cd2..a3bb51b 100644
--- a/app.py
+++ b/app.py
@@ -20,7 +20,7 @@ logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
app = Flask(__name__)
-app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///data/timetrack.db'
+app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////data/timetrack.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev_key_for_timetrack')
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # Session lasts for 7 days
From 66273561d81418bc34e6e3cf9e9cbcf4d455bb22 Mon Sep 17 00:00:00 2001
From: Jens Luedicke
Date: Tue, 1 Jul 2025 14:35:10 +0200
Subject: [PATCH 12/16] Set permissions on /data
---
Dockerfile | 1 +
1 file changed, 1 insertion(+)
diff --git a/Dockerfile b/Dockerfile
index 59e295b..b6963ac 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -27,6 +27,7 @@ COPY . .
RUN mkdir -p /app/instance && chmod 777 /app/instance
VOLUME /data
+RUN mkdir /data && chmod 777 /data
# Expose the port the app runs on
EXPOSE 5000
From 6c27bdeea744cc3145837d3cab72cd207c451a8c Mon Sep 17 00:00:00 2001
From: Jens Luedicke
Date: Tue, 1 Jul 2025 14:47:50 +0200
Subject: [PATCH 13/16] Run DB migrations on app start.
---
README.md | 38 ++++----
app.py | 278 +++++++++++++++++++++++++++++++++++++++++++++++++-----
2 files changed, 276 insertions(+), 40 deletions(-)
diff --git a/README.md b/README.md
index 7e1d533..eb2d3b5 100644
--- a/README.md
+++ b/README.md
@@ -65,31 +65,37 @@ pipenv install
# Activate the virtual environment
pipenv shell
-# Initialize the database and run migrations
-python migrate_db.py
-python migrate_roles_teams.py # Add role and team support
-python migrate_projects.py # Add project management
-
-# Run the application
+# Run the application (migrations run automatically on first startup)
python app.py
```
### First-Time Setup
-1. **Admin Account**: Create the first admin user through the registration page
-2. **System Configuration**: Access Admin Dashboard to configure system settings
-3. **Team Setup**: Create teams and assign team leaders
-4. **Project Creation**: Set up projects with codes and team assignments
-5. **User Management**: Add users and assign appropriate roles
+1. **Start the Application**: The database is automatically created and initialized on first startup
+2. **Admin Account**: An initial admin user is created automatically with username `admin` and password `admin`
+3. **Change Default Password**: **IMPORTANT**: Change the default admin password immediately after first login
+4. **System Configuration**: Access Admin Dashboard to configure system settings
+5. **Team Setup**: Create teams and assign team leaders
+6. **Project Creation**: Set up projects with codes and team assignments
+7. **User Management**: Add users and assign appropriate roles
### Database Migrations
-The application includes several migration scripts to upgrade existing installations:
+**Automatic Migration System**: All database migrations now run automatically when the application starts. No manual migration scripts need to be run.
-- `migrate_db.py`: Core database initialization
-- `migrate_roles_teams.py`: Add role-based access control and team management
-- `migrate_projects.py`: Add project management capabilities
-- `repair_roles.py`: Fix role assignments if needed
+The integrated migration system handles:
+- Database schema creation for new installations
+- Automatic schema updates for existing databases
+- User table enhancements (verification, roles, teams, 2FA)
+- Project and team management table creation
+- Sample data initialization
+- Data integrity maintenance during upgrades
+
+**Legacy Migration Files**: The following files are maintained for reference but are no longer needed:
+- `migrate_db.py`: Legacy core database migration (now integrated)
+- `migrate_roles_teams.py`: Legacy role and team migration (now integrated)
+- `migrate_projects.py`: Legacy project migration (now integrated)
+- `repair_roles.py`: Legacy role repair utility (functionality now integrated)
### Configuration
diff --git a/app.py b/app.py
index a3bb51b..76cdd3e 100644
--- a/app.py
+++ b/app.py
@@ -45,22 +45,270 @@ mail = Mail(app)
# Initialize the database with the app
db.init_app(app)
-# Add this function to initialize system settings
+# Integrated migration and initialization function
+def run_migrations():
+ """Run all database migrations and initialize system settings."""
+ import sqlite3
+
+ # Determine database path based on environment
+ db_path = '/data/timetrack.db' if os.path.exists('/data') else 'timetrack.db'
+
+ # Check if database exists
+ if not os.path.exists(db_path):
+ print("Database doesn't exist. Creating new database.")
+ db.create_all()
+ init_system_settings()
+ return
+
+ print("Running database migrations...")
+
+ # Connect to the database for raw SQL operations
+ conn = sqlite3.connect(db_path)
+ cursor = conn.cursor()
+
+ try:
+ # Migrate time_entry table
+ cursor.execute("PRAGMA table_info(time_entry)")
+ time_entry_columns = [column[1] for column in cursor.fetchall()]
+
+ migrations = [
+ ('is_paused', "ALTER TABLE time_entry ADD COLUMN is_paused BOOLEAN DEFAULT 0"),
+ ('pause_start_time', "ALTER TABLE time_entry ADD COLUMN pause_start_time TIMESTAMP"),
+ ('total_break_duration', "ALTER TABLE time_entry ADD COLUMN total_break_duration INTEGER DEFAULT 0"),
+ ('user_id', "ALTER TABLE time_entry ADD COLUMN user_id INTEGER"),
+ ('project_id', "ALTER TABLE time_entry ADD COLUMN project_id INTEGER"),
+ ('notes', "ALTER TABLE time_entry ADD COLUMN notes TEXT")
+ ]
+
+ for column_name, sql_command in migrations:
+ if column_name not in time_entry_columns:
+ print(f"Adding {column_name} column to time_entry...")
+ cursor.execute(sql_command)
+
+ # Create work_config table if it doesn't exist
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='work_config'")
+ if not cursor.fetchone():
+ print("Creating work_config table...")
+ cursor.execute("""
+ CREATE TABLE work_config (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ work_hours_per_day FLOAT DEFAULT 8.0,
+ mandatory_break_minutes INTEGER DEFAULT 30,
+ break_threshold_hours FLOAT DEFAULT 6.0,
+ additional_break_minutes INTEGER DEFAULT 15,
+ additional_break_threshold_hours FLOAT DEFAULT 9.0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ user_id INTEGER
+ )
+ """)
+ cursor.execute("""
+ INSERT INTO work_config (work_hours_per_day, mandatory_break_minutes, break_threshold_hours)
+ VALUES (8.0, 30, 6.0)
+ """)
+ else:
+ # Check and add missing columns to work_config
+ cursor.execute("PRAGMA table_info(work_config)")
+ work_config_columns = [column[1] for column in cursor.fetchall()]
+
+ work_config_migrations = [
+ ('additional_break_minutes', "ALTER TABLE work_config ADD COLUMN additional_break_minutes INTEGER DEFAULT 15"),
+ ('additional_break_threshold_hours', "ALTER TABLE work_config ADD COLUMN additional_break_threshold_hours FLOAT DEFAULT 9.0"),
+ ('user_id', "ALTER TABLE work_config ADD COLUMN user_id INTEGER")
+ ]
+
+ for column_name, sql_command in work_config_migrations:
+ if column_name not in work_config_columns:
+ print(f"Adding {column_name} column to work_config...")
+ cursor.execute(sql_command)
+
+ # Migrate user table
+ cursor.execute("PRAGMA table_info(user)")
+ user_columns = [column[1] for column in cursor.fetchall()]
+
+ user_migrations = [
+ ('is_verified', "ALTER TABLE user ADD COLUMN is_verified BOOLEAN DEFAULT 0"),
+ ('verification_token', "ALTER TABLE user ADD COLUMN verification_token VARCHAR(100)"),
+ ('token_expiry', "ALTER TABLE user ADD COLUMN token_expiry TIMESTAMP"),
+ ('is_blocked', "ALTER TABLE user ADD COLUMN is_blocked BOOLEAN DEFAULT 0"),
+ ('role', "ALTER TABLE user ADD COLUMN role VARCHAR(50) DEFAULT 'Team Member'"),
+ ('team_id', "ALTER TABLE user ADD COLUMN team_id INTEGER"),
+ ('two_factor_enabled', "ALTER TABLE user ADD COLUMN two_factor_enabled BOOLEAN DEFAULT 0"),
+ ('two_factor_secret', "ALTER TABLE user ADD COLUMN two_factor_secret VARCHAR(32)")
+ ]
+
+ for column_name, sql_command in user_migrations:
+ if column_name not in user_columns:
+ print(f"Adding {column_name} column to user...")
+ cursor.execute(sql_command)
+
+ # Create team table if it doesn't exist
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='team'")
+ if not cursor.fetchone():
+ print("Creating team table...")
+ cursor.execute("""
+ CREATE TABLE team (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name VARCHAR(100) UNIQUE NOT NULL,
+ description VARCHAR(255),
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+
+ # Create system_settings table if it doesn't exist
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='system_settings'")
+ if not cursor.fetchone():
+ print("Creating system_settings table...")
+ cursor.execute("""
+ CREATE TABLE system_settings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ key VARCHAR(50) UNIQUE NOT NULL,
+ value VARCHAR(255) NOT NULL,
+ description VARCHAR(255),
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+
+ # Create project table if it doesn't exist
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='project'")
+ if not cursor.fetchone():
+ print("Creating project table...")
+ cursor.execute("""
+ CREATE TABLE project (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name VARCHAR(100) NOT NULL,
+ description TEXT,
+ code VARCHAR(20) NOT NULL UNIQUE,
+ is_active BOOLEAN DEFAULT 1,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ created_by_id INTEGER NOT NULL,
+ team_id INTEGER,
+ start_date DATE,
+ end_date DATE,
+ FOREIGN KEY (created_by_id) REFERENCES user (id),
+ FOREIGN KEY (team_id) REFERENCES team (id)
+ )
+ """)
+
+ # Commit all schema changes
+ conn.commit()
+
+ except Exception as e:
+ print(f"Error during database migration: {e}")
+ conn.rollback()
+ raise
+ finally:
+ conn.close()
+
+ # Now use SQLAlchemy for data migrations
+ db.create_all() # This will create any remaining tables defined in models
+
+ # Initialize system settings
+ init_system_settings()
+
+ # Handle admin user and data migrations
+ migrate_data()
+
+ print("Database migrations completed successfully!")
+
def init_system_settings():
- # Check if registration_enabled setting exists, if not create it
+ """Initialize system settings with default values if they don't exist"""
if not SystemSettings.query.filter_by(key='registration_enabled').first():
- registration_setting = SystemSettings(
+ print("Adding registration_enabled system setting...")
+ reg_setting = SystemSettings(
key='registration_enabled',
value='true',
description='Controls whether new user registration is allowed'
)
- db.session.add(registration_setting)
+ db.session.add(reg_setting)
db.session.commit()
-# Call this function during app initialization (add it where you initialize the app)
+def migrate_data():
+ """Handle data migrations and setup"""
+ # Check if admin user exists
+ admin = User.query.filter_by(username='admin').first()
+ if not admin:
+ # Create admin user
+ admin = User(
+ username='admin',
+ email='admin@timetrack.local',
+ is_admin=True,
+ is_verified=True,
+ role=Role.ADMIN,
+ two_factor_enabled=False
+ )
+ admin.set_password('admin')
+ db.session.add(admin)
+ db.session.commit()
+ print("Created admin user with username 'admin' and password 'admin'")
+ print("IMPORTANT: Change the admin password after first login!")
+ else:
+ # Update existing admin user with new fields
+ admin.is_verified = True
+ if not admin.role:
+ admin.role = Role.ADMIN
+ if admin.two_factor_enabled is None:
+ admin.two_factor_enabled = False
+ db.session.commit()
+
+ # Update orphaned records
+ orphan_entries = TimeEntry.query.filter_by(user_id=None).all()
+ for entry in orphan_entries:
+ entry.user_id = admin.id
+
+ orphan_configs = WorkConfig.query.filter_by(user_id=None).all()
+ for config in orphan_configs:
+ config.user_id = admin.id
+
+ # Update existing users
+ users_to_update = User.query.all()
+ for user in users_to_update:
+ if user.is_verified is None:
+ user.is_verified = True
+ if not user.role:
+ user.role = Role.ADMIN if user.is_admin else Role.TEAM_MEMBER
+ if user.two_factor_enabled is None:
+ user.two_factor_enabled = False
+
+ # Create sample projects if none exist
+ if Project.query.count() == 0:
+ sample_projects = [
+ {
+ 'name': 'General Administration',
+ 'code': 'ADMIN001',
+ 'description': 'General administrative tasks and meetings'
+ },
+ {
+ 'name': 'Development Project',
+ 'code': 'DEV001',
+ 'description': 'Software development and maintenance tasks'
+ },
+ {
+ 'name': 'Customer Support',
+ 'code': 'SUPPORT001',
+ 'description': 'Customer service and technical support activities'
+ }
+ ]
+
+ for proj_data in sample_projects:
+ project = Project(
+ name=proj_data['name'],
+ code=proj_data['code'],
+ description=proj_data['description'],
+ created_by_id=admin.id,
+ is_active=True
+ )
+ db.session.add(project)
+
+ print(f"Created {len(sample_projects)} sample projects")
+
+ db.session.commit()
+
+# Call this function during app initialization
@app.before_first_request
def initialize_app():
- init_system_settings()
+ run_migrations()
# Add this after initializing the app but before defining routes
@app.context_processor
@@ -829,24 +1077,6 @@ def config():
return render_template('config.html', title='Configuration', config=config)
-# Create the database tables before first request
-@app.before_first_request
-def create_tables():
- # This will only create tables that don't exist yet
- db.create_all()
-
- # Check if we need to add new columns
- from sqlalchemy import inspect
- inspector = inspect(db.engine)
-
- # Check if user table exists
- if 'user' in inspector.get_table_names():
- columns = [column['name'] for column in inspector.get_columns('user')]
-
- # Check for verification columns
- if 'is_verified' not in columns or 'verification_token' not in columns or 'token_expiry' not in columns:
- logger.warning("Database schema is outdated. Please run migrate_db.py to update it.")
- print("WARNING: Database schema is outdated. Please run migrate_db.py to update it.")
@app.route('/api/delete/', methods=['DELETE'])
@login_required
From c9ee69712d24de84e9bf88ca6d32f508fd4f4613 Mon Sep 17 00:00:00 2001
From: Jens Luedicke
Date: Tue, 1 Jul 2025 15:03:03 +0200
Subject: [PATCH 14/16] Fix for DB initialization.
---
README.md | 4 ++--
app.py | 16 ++++++++++++++--
2 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index eb2d3b5..9af5d3c 100644
--- a/README.md
+++ b/README.md
@@ -145,11 +145,11 @@ The application provides various endpoints for different user roles:
## File Structure
-- `app.py`: Main Flask application
+- `app.py`: Main Flask application with integrated migration system
- `models.py`: Database models and relationships
- `templates/`: HTML templates for all pages
- `static/`: CSS and JavaScript files
-- `migrate_*.py`: Database migration scripts
+- `migrate_*.py`: Legacy migration scripts (no longer needed)
## Contributing
diff --git a/app.py b/app.py
index 76cdd3e..0d86876 100644
--- a/app.py
+++ b/app.py
@@ -56,8 +56,9 @@ def run_migrations():
# Check if database exists
if not os.path.exists(db_path):
print("Database doesn't exist. Creating new database.")
- db.create_all()
- init_system_settings()
+ with app.app_context():
+ db.create_all()
+ init_system_settings()
return
print("Running database migrations...")
@@ -67,6 +68,17 @@ def run_migrations():
cursor = conn.cursor()
try:
+ # Check if time_entry table exists first
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='time_entry'")
+ if not cursor.fetchone():
+ print("time_entry table doesn't exist. Creating all tables...")
+ with app.app_context():
+ db.create_all()
+ init_system_settings()
+ conn.commit()
+ conn.close()
+ return
+
# Migrate time_entry table
cursor.execute("PRAGMA table_info(time_entry)")
time_entry_columns = [column[1] for column in cursor.fetchall()]
From 85847b5d39f1ca7158f320891f1d707a240be2f6 Mon Sep 17 00:00:00 2001
From: Jens Luedicke
Date: Tue, 1 Jul 2025 23:45:03 +0200
Subject: [PATCH 15/16] Add setting to disable user email verification.
---
app.py | 46 +++++++++++++++++++++++++++++------
migrate_db.py | 13 ++++++++++
templates/admin_settings.html | 12 ++++++++-
3 files changed, 63 insertions(+), 8 deletions(-)
diff --git a/app.py b/app.py
index 0d86876..9c03ecc 100644
--- a/app.py
+++ b/app.py
@@ -235,6 +235,16 @@ def init_system_settings():
)
db.session.add(reg_setting)
db.session.commit()
+
+ if not SystemSettings.query.filter_by(key='email_verification_required').first():
+ print("Adding email_verification_required system setting...")
+ email_setting = SystemSettings(
+ key='email_verification_required',
+ value='true',
+ description='Controls whether email verification is required for new user accounts'
+ )
+ db.session.add(email_setting)
+ db.session.commit()
def migrate_data():
"""Handle data migrations and setup"""
@@ -350,6 +360,11 @@ def admin_required(f):
return f(*args, **kwargs)
return decorated_function
+def get_system_setting(key, default='false'):
+ """Helper function to get system setting value"""
+ setting = SystemSettings.query.filter_by(key=key).first()
+ return setting.value if setting else default
+
# Add this decorator function after your existing decorators
def role_required(min_role):
"""
@@ -492,8 +507,7 @@ def logout():
@app.route('/register', methods=['GET', 'POST'])
def register():
# Check if registration is enabled
- reg_setting = SystemSettings.query.filter_by(key='registration_enabled').first()
- registration_enabled = reg_setting and reg_setting.value == 'true'
+ registration_enabled = get_system_setting('registration_enabled', 'true') == 'true'
if not registration_enabled:
flash('Registration is currently disabled by the administrator.', 'error')
@@ -524,6 +538,9 @@ def register():
try:
# Check if this is the first user account
is_first_user = User.query.count() == 0
+
+ # Check if email verification is required
+ email_verification_required = get_system_setting('email_verification_required', 'true') == 'true'
new_user = User(username=username, email=email, is_verified=False)
new_user.set_password(password)
@@ -533,8 +550,11 @@ def register():
new_user.is_admin = True
new_user.role = Role.ADMIN
new_user.is_verified = True # Auto-verify first user
+ elif not email_verification_required:
+ # If email verification is disabled, auto-verify new users
+ new_user.is_verified = True
- # Generate verification token
+ # Generate verification token (even if not needed, for consistency)
token = new_user.generate_verification_token()
db.session.add(new_user)
@@ -544,8 +564,12 @@ def register():
# First user gets admin privileges and is auto-verified
logger.info(f"First user account created: {username} with admin privileges")
flash('Welcome! You are the first user and have been granted administrator privileges. You can now log in.', 'success')
+ elif not email_verification_required:
+ # Email verification is disabled, user can log in immediately
+ logger.info(f"User account created with auto-verification: {username}")
+ flash('Registration successful! You can now log in.', 'success')
else:
- # Send verification email for regular users
+ # Send verification email for regular users when verification is required
verification_url = url_for('verify_email', token=token, _external=True)
msg = Message('Verify your TimeTrack account', recipients=[email])
msg.body = f'''Hello {username},
@@ -1333,18 +1357,26 @@ def admin_settings():
if request.method == 'POST':
# Update registration setting
registration_enabled = 'registration_enabled' in request.form
-
reg_setting = SystemSettings.query.filter_by(key='registration_enabled').first()
if reg_setting:
reg_setting.value = 'true' if registration_enabled else 'false'
- db.session.commit()
- flash('System settings updated successfully!', 'success')
+
+ # Update email verification setting
+ email_verification_required = 'email_verification_required' in request.form
+ email_setting = SystemSettings.query.filter_by(key='email_verification_required').first()
+ if email_setting:
+ email_setting.value = 'true' if email_verification_required else 'false'
+
+ db.session.commit()
+ flash('System settings updated successfully!', 'success')
# Get current settings
settings = {}
for setting in SystemSettings.query.all():
if setting.key == 'registration_enabled':
settings['registration_enabled'] = setting.value == 'true'
+ elif setting.key == 'email_verification_required':
+ settings['email_verification_required'] = setting.value == 'true'
return render_template('admin_settings.html', title='System Settings', settings=settings)
diff --git a/migrate_db.py b/migrate_db.py
index af61e0b..df24de8 100644
--- a/migrate_db.py
+++ b/migrate_db.py
@@ -316,6 +316,19 @@ def init_system_settings():
db.session.add(reg_setting)
db.session.commit()
print("Registration setting initialized to enabled")
+
+ # Check if email_verification_required setting exists
+ email_verification_setting = SystemSettings.query.filter_by(key='email_verification_required').first()
+ if not email_verification_setting:
+ print("Adding email_verification_required system setting...")
+ email_verification_setting = SystemSettings(
+ key='email_verification_required',
+ value='true', # Default to enabled for security
+ description='Controls whether email verification is required for new user accounts'
+ )
+ db.session.add(email_verification_setting)
+ db.session.commit()
+ print("Email verification setting initialized to enabled")
if __name__ == "__main__":
migrate_database()
diff --git a/templates/admin_settings.html b/templates/admin_settings.html
index c076d38..68805c7 100644
--- a/templates/admin_settings.html
+++ b/templates/admin_settings.html
@@ -20,7 +20,17 @@
-
+