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 @@

- +
+ +

+ When enabled, new users must verify their email address before accessing the application. When disabled, new users can log in immediately after registration. +

+
From 8f49958dfa9a2ef95c3c6957aa7902a6d746a0fb Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Wed, 2 Jul 2025 12:42:18 +0200 Subject: [PATCH 16/16] Add company feature. --- app.py | 785 +++++++++++++++++++++++++++-------- migrate_db.py | 6 +- migrate_projects.py | 4 +- migrate_roles_teams.py | 4 +- models.py | 65 ++- repair_roles.py | 2 +- templates/admin_company.html | 213 ++++++++++ templates/admin_users.html | 2 +- templates/company_users.html | 202 +++++++++ templates/create_user.html | 20 +- templates/dashboard.html | 12 +- templates/edit_company.html | 73 ++++ templates/edit_user.html | 6 - templates/layout.html | 15 +- templates/profile.html | 2 +- templates/register.html | 9 +- templates/setup_company.html | 261 ++++++++++++ 17 files changed, 1465 insertions(+), 216 deletions(-) create mode 100644 templates/admin_company.html create mode 100644 templates/company_users.html create mode 100644 templates/edit_company.html create mode 100644 templates/setup_company.html diff --git a/app.py b/app.py index 9c03ecc..a76550f 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file -from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project +from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company import logging from datetime import datetime, time, timedelta import os @@ -154,6 +154,53 @@ def run_migrations(): print(f"Adding {column_name} column to user...") cursor.execute(sql_command) + # Remove is_admin column if it exists (migration to role-based system) + if 'is_admin' in user_columns: + print("Migrating from is_admin to role-based system...") + # First ensure all users have roles set based on is_admin + cursor.execute("UPDATE user SET role = 'Administrator' WHERE is_admin = 1 AND (role IS NULL OR role = '')") + cursor.execute("UPDATE user SET role = 'Team Member' WHERE is_admin = 0 AND (role IS NULL OR role = '')") + + # Drop the is_admin column (SQLite requires table recreation) + print("Removing is_admin column...") + cursor.execute("PRAGMA foreign_keys=off") + + # Create new table without is_admin column + cursor.execute(""" + CREATE TABLE user_new ( + id INTEGER PRIMARY KEY, + username VARCHAR(80) UNIQUE NOT NULL, + email VARCHAR(120) UNIQUE NOT NULL, + password_hash VARCHAR(128), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_verified BOOLEAN DEFAULT 0, + verification_token VARCHAR(100), + token_expiry TIMESTAMP, + is_blocked BOOLEAN DEFAULT 0, + role VARCHAR(50) DEFAULT 'Team Member', + team_id INTEGER, + two_factor_enabled BOOLEAN DEFAULT 0, + two_factor_secret VARCHAR(32) + ) + """) + + # Copy data from old table to new table (excluding is_admin) + cursor.execute(""" + INSERT INTO user_new (id, username, email, password_hash, created_at, is_verified, + verification_token, token_expiry, is_blocked, role, team_id, + two_factor_enabled, two_factor_secret) + SELECT id, username, email, password_hash, created_at, is_verified, + verification_token, token_expiry, is_blocked, role, team_id, + two_factor_enabled, two_factor_secret + FROM user + """) + + # Drop old table and rename new table + cursor.execute("DROP TABLE user") + cursor.execute("ALTER TABLE user_new RENAME TO user") + cursor.execute("PRAGMA foreign_keys=on") + print("Successfully removed is_admin column") + # 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(): @@ -190,7 +237,7 @@ def run_migrations(): id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL, description TEXT, - code VARCHAR(20) NOT NULL UNIQUE, + code VARCHAR(20) NOT NULL, is_active BOOLEAN DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -198,8 +245,26 @@ def run_migrations(): team_id INTEGER, start_date DATE, end_date DATE, + company_id INTEGER, FOREIGN KEY (created_by_id) REFERENCES user (id), - FOREIGN KEY (team_id) REFERENCES team (id) + FOREIGN KEY (team_id) REFERENCES team (id), + FOREIGN KEY (company_id) REFERENCES company (id) + ) + """) + + # Create company table if it doesn't exist + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='company'") + if not cursor.fetchone(): + print("Creating company table...") + cursor.execute(""" + CREATE TABLE company ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) UNIQUE NOT NULL, + slug VARCHAR(50) UNIQUE NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT 1, + max_users INTEGER DEFAULT 100 ) """) @@ -219,11 +284,84 @@ def run_migrations(): # Initialize system settings init_system_settings() - # Handle admin user and data migrations + # Handle company migration and admin user setup + migrate_to_company_model() migrate_data() print("Database migrations completed successfully!") +def migrate_to_company_model(): + """Migrate existing data to support company model""" + import sqlite3 + + # Determine database path based on environment + db_path = '/data/timetrack.db' if os.path.exists('/data') else 'timetrack.db' + + # Connect to the database for raw SQL operations + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + # Check if company_id column exists in user table + cursor.execute("PRAGMA table_info(user)") + user_columns = [column[1] for column in cursor.fetchall()] + + if 'company_id' not in user_columns: + print("Migrating to company model...") + + # Add company_id columns to existing tables + tables_to_migrate = [ + ('user', 'ALTER TABLE user ADD COLUMN company_id INTEGER'), + ('team', 'ALTER TABLE team ADD COLUMN company_id INTEGER'), + ('project', 'ALTER TABLE project ADD COLUMN company_id INTEGER') + ] + + for table_name, sql_command in tables_to_migrate: + cursor.execute(f"PRAGMA table_info({table_name})") + columns = [column[1] for column in cursor.fetchall()] + if 'company_id' not in columns: + print(f"Adding company_id column to {table_name}...") + cursor.execute(sql_command) + + # Check if there are existing users but no companies + cursor.execute("SELECT COUNT(*) FROM user") + user_count = cursor.fetchone()[0] + + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='company'") + company_table_exists = cursor.fetchone() + + if user_count > 0 and company_table_exists: + cursor.execute("SELECT COUNT(*) FROM company") + company_count = cursor.fetchone()[0] + + if company_count == 0: + print("Creating default company for existing data...") + # Create default company + cursor.execute(""" + INSERT INTO company (name, slug, description, is_active, max_users) + VALUES ('Default Organization', 'default', 'Migrated from single-tenant installation', 1, 1000) + """) + + # Get the company ID + cursor.execute("SELECT last_insert_rowid()") + company_id = cursor.fetchone()[0] + + # Update all existing records to use the default company + cursor.execute(f"UPDATE user SET company_id = {company_id} WHERE company_id IS NULL") + cursor.execute(f"UPDATE team SET company_id = {company_id} WHERE company_id IS NULL") + cursor.execute(f"UPDATE project SET company_id = {company_id} WHERE company_id IS NULL") + + print(f"Assigned {user_count} existing users to default company") + + conn.commit() + + except Exception as e: + print(f"Error during company migration: {e}") + conn.rollback() + raise + finally: + conn.close() + def init_system_settings(): """Initialize system settings with default values if they don't exist""" if not SystemSettings.query.filter_by(key='registration_enabled').first(): @@ -248,84 +386,93 @@ def init_system_settings(): 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 + # Only create default admin if no companies exist yet + if Company.query.count() == 0: + print("No companies exist, skipping admin user creation. Use company setup instead.") + return + + # Check if admin user exists in the first company + default_company = Company.query.first() + if default_company: + admin = User.query.filter_by(username='admin', company_id=default_company.id).first() + if not admin: + # Create admin user for the default company + admin = User( + username='admin', + email='admin@timetrack.local', + company_id=default_company.id, + is_verified=True, + role=Role.ADMIN, + two_factor_enabled=False ) - db.session.add(project) - - print(f"Created {len(sample_projects)} sample projects") + 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() - 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.filter_by(company_id=default_company.id).all() + for user in users_to_update: + if user.is_verified is None: + user.is_verified = True + if not user.role: + user.role = Role.TEAM_MEMBER + if user.two_factor_enabled is None: + user.two_factor_enabled = False + + # Create sample projects if none exist for this company + existing_projects = Project.query.filter_by(company_id=default_company.id).count() + if existing_projects == 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'], + company_id=default_company.id, + created_by_id=admin.id, + is_active=True + ) + db.session.add(project) + + print(f"Created {len(sample_projects)} sample projects for {default_company.name}") + + db.session.commit() # Call this function during app initialization @app.before_first_request @@ -354,7 +501,7 @@ def login_required(f): def admin_required(f): @wraps(f) def decorated_function(*args, **kwargs): - if g.user is None or not g.user.is_admin: + if g.user is None or g.user.role != Role.ADMIN: flash('You need administrator privileges to access this page.', 'error') return redirect(url_for('home')) return f(*args, **kwargs) @@ -378,7 +525,7 @@ def role_required(min_role): return redirect(url_for('login', next=request.url)) # Admin always has access - if g.user.is_admin: + if g.user.role == Role.ADMIN: return f(*args, **kwargs) # Check role hierarchy @@ -397,19 +544,52 @@ def role_required(min_role): return decorated_function return role_decorator +def company_required(f): + """ + Decorator to ensure user has a valid company association and set company context. + """ + @wraps(f) + def decorated_function(*args, **kwargs): + if g.user is None: + return redirect(url_for('login', next=request.url)) + + if g.user.company_id is None: + flash('You must be associated with a company to access this page.', 'error') + return redirect(url_for('setup_company')) + + # Set company context + g.company = Company.query.get(g.user.company_id) + if not g.company or not g.company.is_active: + flash('Your company is not active. Please contact support.', 'error') + return redirect(url_for('login')) + + return f(*args, **kwargs) + return decorated_function + @app.before_request def load_logged_in_user(): user_id = session.get('user_id') if user_id is None: g.user = None + g.company = None else: g.user = User.query.get(user_id) - if g.user and not g.user.is_verified and request.endpoint not in ['verify_email', 'static', 'logout']: - # Allow unverified users to access only verification and static resources - if request.endpoint not in ['login', 'register']: - flash('Please verify your email address before accessing this page.', 'warning') - session.clear() - return redirect(url_for('login')) + if g.user: + # Set company context + if g.user.company_id: + g.company = Company.query.get(g.user.company_id) + else: + g.company = None + + # Check if user is verified + if not g.user.is_verified and request.endpoint not in ['verify_email', 'static', 'logout', 'setup_company']: + # Allow unverified users to access only verification and static resources + if request.endpoint not in ['login', 'register']: + flash('Please verify your email address before accessing this page.', 'warning') + session.clear() + return redirect(url_for('login')) + else: + g.company = None @app.route('/') def home(): @@ -429,12 +609,16 @@ def home(): TimeEntry.arrival_time <= datetime.combine(today, time.max) ).order_by(TimeEntry.arrival_time.desc()).all() - # Get available projects for this user + # Get available projects for this user (company-scoped) 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) + if g.user.company_id: + all_projects = Project.query.filter_by( + company_id=g.user.company_id, + 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, @@ -469,7 +653,7 @@ def login(): 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 + user.role = Role.ADMIN if user.role == Role.ADMIN else Role.TEAM_MEMBER db.session.commit() @@ -489,7 +673,7 @@ def login(): # Continue with normal login process session['user_id'] = user.id session['username'] = user.username - session['is_admin'] = user.is_admin + session['role'] = user.role.value flash('Login successful!', 'success') return redirect(url_for('home')) @@ -513,11 +697,17 @@ def register(): flash('Registration is currently disabled by the administrator.', 'error') return redirect(url_for('login')) + # Check if companies exist, if not redirect to company setup + if Company.query.count() == 0: + flash('No companies exist yet. Please set up your company first.', 'info') + return redirect(url_for('setup_company')) + 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') + company_code = request.form.get('company_code', '').strip() # Validate input error = None @@ -529,27 +719,43 @@ def register(): error = 'Password is required' elif password != confirm_password: error = 'Passwords do not match' - elif User.query.filter_by(username=username).first(): - error = 'Username already exists' - elif User.query.filter_by(email=email).first(): - error = 'Email already registered' + elif not company_code: + error = 'Company code is required' + + # Find company by code + company = None + if company_code: + company = Company.query.filter_by(slug=company_code.lower()).first() + if not company: + error = 'Invalid company code' + + # Check for existing users within the company + if company and not error: + if User.query.filter_by(username=username, company_id=company.id).first(): + error = 'Username already exists in this company' + elif User.query.filter_by(email=email, company_id=company.id).first(): + error = 'Email already registered in this company' - if error is None: + if error is None and company: try: - # Check if this is the first user account - is_first_user = User.query.count() == 0 + # Check if this is the first user account in this company + is_first_user_in_company = User.query.filter_by(company_id=company.id).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 = User( + username=username, + email=email, + company_id=company.id, + 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 + # Make first user in company an admin with full privileges + if is_first_user_in_company: new_user.role = Role.ADMIN - new_user.is_verified = True # Auto-verify first user + new_user.is_verified = True # Auto-verify first user in company elif not email_verification_required: # If email verification is disabled, auto-verify new users new_user.is_verified = True @@ -560,13 +766,13 @@ def register(): 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") - flash('Welcome! You are the first user and have been granted administrator privileges. You can now log in.', 'success') + if is_first_user_in_company: + # First user in company gets admin privileges and is auto-verified + logger.info(f"First user account created in company {company.name}: {username} with admin privileges") + flash(f'Welcome! You are the first user in {company.name} 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}") + logger.info(f"User account created with auto-verification in company {company.name}: {username}") flash('Registration successful! You can now log in.', 'success') else: # Send verification email for regular users when verification is required @@ -599,6 +805,125 @@ The TimeTrack Team return render_template('register.html', title='Register') +@app.route('/setup_company', methods=['GET', 'POST']) +def setup_company(): + """Company setup route for creating new companies with admin users""" + existing_companies = Company.query.count() + + # Determine access level + is_initial_setup = existing_companies == 0 + is_super_admin = g.user and g.user.role == Role.ADMIN and existing_companies > 0 + is_authorized = is_initial_setup or is_super_admin + + # Check authorization for non-initial setups + if not is_initial_setup and not is_super_admin: + flash('You do not have permission to create new companies.', 'error') + return redirect(url_for('home') if g.user else url_for('login')) + + if request.method == 'POST': + company_name = request.form.get('company_name') + company_description = request.form.get('company_description', '') + admin_username = request.form.get('admin_username') + admin_email = request.form.get('admin_email') + admin_password = request.form.get('admin_password') + confirm_password = request.form.get('confirm_password') + + # Validate input + error = None + if not company_name: + error = 'Company name is required' + elif not admin_username: + error = 'Admin username is required' + elif not admin_email: + error = 'Admin email is required' + elif not admin_password: + error = 'Admin password is required' + elif admin_password != confirm_password: + error = 'Passwords do not match' + elif len(admin_password) < 6: + error = 'Password must be at least 6 characters long' + + if error is None: + try: + # Generate company slug + import re + slug = re.sub(r'[^\w\s-]', '', company_name.lower()) + slug = re.sub(r'[-\s]+', '-', slug).strip('-') + + # Ensure slug uniqueness + base_slug = slug + counter = 1 + while Company.query.filter_by(slug=slug).first(): + slug = f"{base_slug}-{counter}" + counter += 1 + + # Create company + company = Company( + name=company_name, + slug=slug, + description=company_description, + is_active=True + ) + db.session.add(company) + db.session.flush() # Get company.id without committing + + # Check if username/email already exists in this company context + existing_user_by_username = User.query.filter_by( + username=admin_username, + company_id=company.id + ).first() + existing_user_by_email = User.query.filter_by( + email=admin_email, + company_id=company.id + ).first() + + if existing_user_by_username: + error = 'Username already exists in this company' + elif existing_user_by_email: + error = 'Email already registered in this company' + + if error is None: + # Create admin user + admin_user = User( + username=admin_username, + email=admin_email, + company_id=company.id, + role=Role.ADMIN, + is_verified=True # Auto-verify company admin + ) + admin_user.set_password(admin_password) + db.session.add(admin_user) + db.session.commit() + + if is_initial_setup: + # Auto-login the admin user for initial setup + session['user_id'] = admin_user.id + session['username'] = admin_user.username + session['role'] = admin_user.role.value + + flash(f'Company "{company_name}" created successfully! You are now logged in as the administrator.', 'success') + return redirect(url_for('home')) + else: + # For super admin creating additional companies, don't auto-login + flash(f'Company "{company_name}" created successfully! Admin user "{admin_username}" has been created with the company code "{slug}".', 'success') + return redirect(url_for('admin_company') if g.user else url_for('login')) + else: + db.session.rollback() + + except Exception as e: + db.session.rollback() + logger.error(f"Error during company setup: {str(e)}") + error = f"An error occurred during setup: {str(e)}" + + if error: + flash(error, 'error') + + return render_template('setup_company.html', + title='Company Setup', + existing_companies=existing_companies, + is_initial_setup=is_initial_setup, + is_super_admin=is_super_admin) + @app.route('/verify_email/') def verify_email(token): user = User.query.filter_by(verification_token=token).first() @@ -621,27 +946,33 @@ 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 + if g.user.role == Role.ADMIN and g.user.company_id: + # Admin sees everything within their company dashboard_data.update({ - 'total_users': User.query.count(), - 'total_teams': Team.query.count(), - 'blocked_users': User.query.filter_by(is_blocked=True).count(), - 'unverified_users': User.query.filter_by(is_verified=False).count(), - 'recent_registrations': User.query.order_by(User.id.desc()).limit(5).all() + 'total_users': User.query.filter_by(company_id=g.user.company_id).count(), + 'total_teams': Team.query.filter_by(company_id=g.user.company_id).count(), + 'blocked_users': User.query.filter_by(company_id=g.user.company_id, is_blocked=True).count(), + 'unverified_users': User.query.filter_by(company_id=g.user.company_id, is_verified=False).count(), + 'recent_registrations': User.query.filter_by(company_id=g.user.company_id).order_by(User.id.desc()).limit(5).all() }) - if g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] or g.user.is_admin: + if g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN]: # Team leaders and supervisors see team-related data - if g.user.team_id or g.user.is_admin: - if g.user.is_admin: - # Admin can see all teams - teams = Team.query.all() - team_members = User.query.filter(User.team_id.isnot(None)).all() + if g.user.team_id or g.user.role == Role.ADMIN: + if g.user.role == Role.ADMIN and g.user.company_id: + # Admin can see all teams in their company + teams = Team.query.filter_by(company_id=g.user.company_id).all() + team_members = User.query.filter( + User.team_id.isnot(None), + User.company_id == g.user.company_id + ).all() else: # 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 [] + team_members = User.query.filter_by( + team_id=g.user.team_id, + company_id=g.user.company_id + ).all() if g.user.team_id else [] dashboard_data.update({ 'teams': teams, @@ -650,7 +981,7 @@ def dashboard(): }) # Get recent time entries for the user's oversight - if g.user.is_admin: + if g.user.role == Role.ADMIN: # Admin sees all recent entries recent_entries = TimeEntry.query.order_by(TimeEntry.arrival_time.desc()).limit(10).all() elif g.user.team_id: @@ -668,18 +999,19 @@ def dashboard(): @app.route('/admin/users') @admin_required +@company_required def admin_users(): - users = User.query.all() + users = User.query.filter_by(company_id=g.user.company_id).all() return render_template('admin_users.html', title='User Management', users=users) @app.route('/admin/users/create', methods=['GET', 'POST']) @admin_required +@company_required def create_user(): 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 auto_verify = 'auto_verify' in request.form # Get role and team @@ -694,10 +1026,10 @@ def create_user(): error = 'Email is required' elif not password: error = 'Password is required' - elif User.query.filter_by(username=username).first(): - error = 'Username already exists' - elif User.query.filter_by(email=email).first(): - error = 'Email already registered' + elif User.query.filter_by(username=username, company_id=g.user.company_id).first(): + error = 'Username already exists in your company' + elif User.query.filter_by(email=email, company_id=g.user.company_id).first(): + error = 'Email already registered in your company' if error is None: # Convert role string to enum @@ -710,7 +1042,7 @@ def create_user(): new_user = User( username=username, email=email, - is_admin=is_admin, + company_id=g.user.company_id, is_verified=auto_verify, role=role, team_id=team_id if team_id else None @@ -746,22 +1078,22 @@ The TimeTrack Team flash(error, 'error') - # Get all teams for the form - teams = Team.query.all() + # Get all teams for the form (company-scoped) + teams = Team.query.filter_by(company_id=g.user.company_id).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 +@company_required def edit_user(user_id): - user = User.query.get_or_404(user_id) + user = User.query.filter_by(id=user_id, company_id=g.user.company_id).first_or_404() 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') @@ -773,15 +1105,14 @@ def edit_user(user_id): error = 'Username is required' elif not email: error = 'Email is required' - elif username != user.username and User.query.filter_by(username=username).first(): - error = 'Username already exists' - elif email != user.email and User.query.filter_by(email=email).first(): - error = 'Email already registered' + elif username != user.username and User.query.filter_by(username=username, company_id=g.user.company_id).first(): + error = 'Username already exists in your company' + elif email != user.email and User.query.filter_by(email=email, company_id=g.user.company_id).first(): + error = 'Email already registered in your company' if error is None: user.username = username user.email = email - user.is_admin = is_admin # Convert role string to enum try: @@ -801,16 +1132,17 @@ def edit_user(user_id): flash(error, 'error') - # Get all teams for the form - teams = Team.query.all() + # Get all teams for the form (company-scoped) + teams = Team.query.filter_by(company_id=g.user.company_id).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 +@company_required def delete_user(user_id): - user = User.query.get_or_404(user_id) + user = User.query.filter_by(id=user_id, company_id=g.user.company_id).first_or_404() # Prevent deleting yourself if user.id == session.get('user_id'): @@ -958,7 +1290,7 @@ def verify_2fa(): session.pop('2fa_user_id', None) session['user_id'] = user.id session['username'] = user.username - session['is_admin'] = user.is_admin + session['role'] = user.role.value flash('Login successful!', 'success') return redirect(url_for('home')) @@ -1165,6 +1497,7 @@ def update_entry(entry_id): @app.route('/team/hours') @login_required @role_required(Role.TEAM_LEADER) # Only team leaders and above can access +@company_required def team_hours(): # Get the current user's team team = Team.query.get(g.user.team_id) @@ -1230,12 +1563,13 @@ def history(): # 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 + # Get available projects for the filter dropdown (company-scoped) 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) + if g.user.company_id: + all_projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all() + for project in all_projects: + if project.is_user_allowed(g.user): + available_projects.append(project) return render_template('history.html', title='Time Entry History', entries=all_entries, available_projects=available_projects) @@ -1331,8 +1665,9 @@ def test(): @app.route('/admin/users/toggle-status/') @admin_required +@company_required def toggle_user_status(user_id): - user = User.query.get_or_404(user_id) + user = User.query.filter_by(id=user_id, company_id=g.user.company_id).first_or_404() # Prevent blocking yourself if user.id == session.get('user_id'): @@ -1380,15 +1715,102 @@ def admin_settings(): return render_template('admin_settings.html', title='System Settings', settings=settings) +# Company Management Routes +@app.route('/admin/company') +@admin_required +@company_required +def admin_company(): + """View and manage company settings""" + company = g.company + + # Get company statistics + stats = { + 'total_users': User.query.filter_by(company_id=company.id).count(), + 'total_teams': Team.query.filter_by(company_id=company.id).count(), + 'total_projects': Project.query.filter_by(company_id=company.id).count(), + 'active_projects': Project.query.filter_by(company_id=company.id, is_active=True).count(), + } + + return render_template('admin_company.html', title='Company Management', company=company, stats=stats) + +@app.route('/admin/company/edit', methods=['GET', 'POST']) +@admin_required +@company_required +def edit_company(): + """Edit company details""" + company = g.company + + if request.method == 'POST': + name = request.form.get('name') + description = request.form.get('description', '') + max_users = request.form.get('max_users') + is_active = 'is_active' in request.form + + # Validate input + error = None + if not name: + error = 'Company name is required' + elif name != company.name and Company.query.filter_by(name=name).first(): + error = 'Company name already exists' + + if max_users: + try: + max_users = int(max_users) + if max_users < 1: + error = 'Maximum users must be at least 1' + except ValueError: + error = 'Maximum users must be a valid number' + else: + max_users = None + + if error is None: + company.name = name + company.description = description + company.max_users = max_users + company.is_active = is_active + db.session.commit() + + flash('Company details updated successfully!', 'success') + return redirect(url_for('admin_company')) + else: + flash(error, 'error') + + return render_template('edit_company.html', title='Edit Company', company=company) + +@app.route('/admin/company/users') +@admin_required +@company_required +def company_users(): + """List all users in the company with detailed information""" + users = User.query.filter_by(company_id=g.company.id).order_by(User.created_at.desc()).all() + + # Calculate user statistics + user_stats = { + 'total': len(users), + 'verified': len([u for u in users if u.is_verified]), + 'unverified': len([u for u in users if not u.is_verified]), + 'blocked': len([u for u in users if u.is_blocked]), + 'active': len([u for u in users if not u.is_blocked and u.is_verified]), + 'admins': len([u for u in users if u.role == Role.ADMIN]), + 'supervisors': len([u for u in users if u.role == Role.SUPERVISOR]), + 'team_leaders': len([u for u in users if u.role == Role.TEAM_LEADER]), + 'team_members': len([u for u in users if u.role == Role.TEAM_MEMBER]), + } + + return render_template('company_users.html', title='Company Users', + users=users, stats=user_stats, company=g.company) + # Add these routes for team management @app.route('/admin/teams') @admin_required +@company_required def admin_teams(): - teams = Team.query.all() + teams = Team.query.filter_by(company_id=g.user.company_id).all() return render_template('admin_teams.html', title='Team Management', teams=teams) @app.route('/admin/teams/create', methods=['GET', 'POST']) @admin_required +@company_required def create_team(): if request.method == 'POST': name = request.form.get('name') @@ -1398,11 +1820,11 @@ def create_team(): error = None if not name: error = 'Team name is required' - elif Team.query.filter_by(name=name).first(): - error = 'Team name already exists' + elif Team.query.filter_by(name=name, company_id=g.user.company_id).first(): + error = 'Team name already exists in your company' if error is None: - new_team = Team(name=name, description=description) + new_team = Team(name=name, description=description, company_id=g.user.company_id) db.session.add(new_team) db.session.commit() @@ -1415,8 +1837,9 @@ def create_team(): @app.route('/admin/teams/edit/', methods=['GET', 'POST']) @admin_required +@company_required def edit_team(team_id): - team = Team.query.get_or_404(team_id) + team = Team.query.filter_by(id=team_id, company_id=g.user.company_id).first_or_404() if request.method == 'POST': name = request.form.get('name') @@ -1426,8 +1849,8 @@ def edit_team(team_id): 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' + elif name != team.name and Team.query.filter_by(name=name, company_id=g.user.company_id).first(): + error = 'Team name already exists in your company' if error is None: team.name = name @@ -1443,8 +1866,9 @@ def edit_team(team_id): @app.route('/admin/teams/delete/', methods=['POST']) @admin_required +@company_required def delete_team(team_id): - team = Team.query.get_or_404(team_id) + team = Team.query.filter_by(id=team_id, company_id=g.user.company_id).first_or_404() # Check if team has members if team.users: @@ -1460,8 +1884,9 @@ def delete_team(team_id): @app.route('/admin/teams/', methods=['GET', 'POST']) @admin_required +@company_required def manage_team(team_id): - team = Team.query.get_or_404(team_id) + team = Team.query.filter_by(id=team_id, company_id=g.user.company_id).first_or_404() if request.method == 'POST': action = request.form.get('action') @@ -1475,8 +1900,8 @@ def manage_team(team_id): 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' + elif name != team.name and Team.query.filter_by(name=name, company_id=g.user.company_id).first(): + error = 'Team name already exists in your company' if error is None: team.name = name @@ -1517,8 +1942,9 @@ def manage_team(team_id): # 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 + # Get users not in this team for the add member form (company-scoped) available_users = User.query.filter( + User.company_id == g.user.company_id, (User.team_id != team.id) | (User.team_id == None) ).all() @@ -1533,12 +1959,14 @@ def manage_team(team_id): # Project Management Routes @app.route('/admin/projects') @role_required(Role.SUPERVISOR) # Supervisors and Admins can manage projects +@company_required def admin_projects(): - projects = Project.query.order_by(Project.created_at.desc()).all() + projects = Project.query.filter_by(company_id=g.user.company_id).order_by(Project.created_at.desc()).all() return render_template('admin_projects.html', title='Project Management', projects=projects) @app.route('/admin/projects/create', methods=['GET', 'POST']) @role_required(Role.SUPERVISOR) +@company_required def create_project(): if request.method == 'POST': name = request.form.get('name') @@ -1554,8 +1982,8 @@ def create_project(): error = 'Project name is required' elif not code: error = 'Project code is required' - elif Project.query.filter_by(code=code).first(): - error = 'Project code already exists' + elif Project.query.filter_by(code=code, company_id=g.user.company_id).first(): + error = 'Project code already exists in your company' # Parse dates start_date = None @@ -1580,6 +2008,7 @@ def create_project(): name=name, description=description, code=code.upper(), + company_id=g.user.company_id, team_id=int(team_id) if team_id else None, start_date=start_date, end_date=end_date, @@ -1592,14 +2021,15 @@ def create_project(): else: flash(error, 'error') - # Get available teams for the form - teams = Team.query.order_by(Team.name).all() + # Get available teams for the form (company-scoped) + teams = Team.query.filter_by(company_id=g.user.company_id).order_by(Team.name).all() return render_template('create_project.html', title='Create Project', teams=teams) @app.route('/admin/projects/edit/', methods=['GET', 'POST']) @role_required(Role.SUPERVISOR) +@company_required def edit_project(project_id): - project = Project.query.get_or_404(project_id) + project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first_or_404() if request.method == 'POST': name = request.form.get('name') @@ -1616,8 +2046,8 @@ def edit_project(project_id): error = 'Project name is required' elif not code: error = 'Project code is required' - elif code != project.code and Project.query.filter_by(code=code).first(): - error = 'Project code already exists' + elif code != project.code and Project.query.filter_by(code=code, company_id=g.user.company_id).first(): + error = 'Project code already exists in your company' # Parse dates start_date = None @@ -1651,14 +2081,15 @@ def edit_project(project_id): else: flash(error, 'error') - # Get available teams for the form - teams = Team.query.order_by(Team.name).all() + # Get available teams for the form (company-scoped) + teams = Team.query.filter_by(company_id=g.user.company_id).order_by(Team.name).all() return render_template('edit_project.html', title='Edit Project', project=project, teams=teams) @app.route('/admin/projects/delete/', methods=['POST']) @role_required(Role.ADMIN) # Only admins can delete projects +@company_required def delete_project(project_id): - project = Project.query.get_or_404(project_id) + project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first_or_404() # Check if there are time entries associated with this project time_entries_count = TimeEntry.query.filter_by(project_id=project_id).count() @@ -1676,6 +2107,7 @@ def delete_project(project_id): @app.route('/api/team/hours_data', methods=['GET']) @login_required @role_required(Role.TEAM_LEADER) # Only team leaders and above can access +@company_required def team_hours_data(): # Get the current user's team team = Team.query.get(g.user.team_id) @@ -2008,6 +2440,7 @@ def download_export(): @app.route('/download_team_hours_export') @login_required @role_required(Role.TEAM_LEADER) +@company_required def download_team_hours_export(): """Handle team hours export download requests.""" export_format = request.args.get('format', 'csv') diff --git a/migrate_db.py b/migrate_db.py index df24de8..27ccba9 100644 --- a/migrate_db.py +++ b/migrate_db.py @@ -205,7 +205,6 @@ def migrate_database(): admin = User( username='admin', email='admin@timetrack.local', - is_admin=True, is_verified=True, # Admin is automatically verified role=Role.ADMIN, two_factor_enabled=False @@ -247,10 +246,7 @@ def migrate_database(): for user in users_to_update: updated = False if not hasattr(user, 'role') or user.role is None: - if user.is_admin: - user.role = Role.ADMIN - else: - user.role = Role.TEAM_MEMBER + user.role = Role.TEAM_MEMBER updated = True if not hasattr(user, 'two_factor_enabled') or user.two_factor_enabled is None: user.two_factor_enabled = False diff --git a/migrate_projects.py b/migrate_projects.py index ebdf956..6421969 100644 --- a/migrate_projects.py +++ b/migrate_projects.py @@ -61,9 +61,7 @@ def migrate_projects(): existing_projects = Project.query.count() if existing_projects == 0: # Find an admin or supervisor user to be the creator - admin_user = User.query.filter_by(is_admin=True).first() - if not admin_user: - admin_user = User.query.filter(User.role.in_([Role.ADMIN, Role.SUPERVISOR])).first() + admin_user = User.query.filter(User.role.in_([Role.ADMIN, Role.SUPERVISOR])).first() if admin_user: # Create some sample projects diff --git a/migrate_roles_teams.py b/migrate_roles_teams.py index 7556b3a..645d0e5 100644 --- a/migrate_roles_teams.py +++ b/migrate_roles_teams.py @@ -73,8 +73,8 @@ def migrate_roles_teams(): # Try to map the string to an enum value user.role = role_mapping.get(user.role, Role.TEAM_MEMBER) elif user.role is None: - # Set default role based on admin status - user.role = Role.ADMIN if user.is_admin else Role.TEAM_MEMBER + # Set default role + user.role = Role.TEAM_MEMBER db.session.commit() logger.info(f"Assigned {len(users)} existing users to default team and updated roles") diff --git a/models.py b/models.py index 6a1c87e..27ce75d 100644 --- a/models.py +++ b/models.py @@ -13,16 +13,49 @@ class Role(enum.Enum): SUPERVISOR = "Supervisor" ADMIN = "Administrator" # Keep existing admin role +# Company model for multi-tenancy +class Company(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False, unique=True) + slug = db.Column(db.String(50), unique=True, nullable=False) # URL-friendly identifier + description = db.Column(db.Text) + created_at = db.Column(db.DateTime, default=datetime.now) + + # Company settings + is_active = db.Column(db.Boolean, default=True) + max_users = db.Column(db.Integer, default=100) # Optional user limit + + # Relationships + users = db.relationship('User', backref='company', lazy=True) + teams = db.relationship('Team', backref='company', lazy=True) + projects = db.relationship('Project', backref='company', lazy=True) + + def __repr__(self): + return f'' + + def generate_slug(self): + """Generate URL-friendly slug from company name""" + import re + slug = re.sub(r'[^\w\s-]', '', self.name.lower()) + slug = re.sub(r'[-\s]+', '-', slug) + return slug.strip('-') + # Create Team model class Team(db.Model): id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(100), nullable=False, unique=True) + name = db.Column(db.String(100), nullable=False) description = db.Column(db.String(255)) created_at = db.Column(db.DateTime, default=datetime.now) + # Company association for multi-tenancy + company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False) + # Relationship with users (one team has many users) users = db.relationship('User', backref='team', lazy=True) + # Unique constraint per company + __table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_team_name_per_company'),) + def __repr__(self): return f'' @@ -30,11 +63,14 @@ class Project(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) description = db.Column(db.Text, nullable=True) - code = db.Column(db.String(20), unique=True, nullable=False) # Project code (e.g., PRJ001) + code = db.Column(db.String(20), nullable=False) # Project code (e.g., PRJ001) is_active = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=datetime.now) updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) + # Company association for multi-tenancy + company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False) + # Foreign key to user who created the project (Admin/Supervisor) created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) @@ -50,6 +86,9 @@ class Project(db.Model): team = db.relationship('Team', backref='projects') time_entries = db.relationship('TimeEntry', backref='project', lazy=True) + # Unique constraint per company + __table_args__ = (db.UniqueConstraint('company_id', 'code', name='uq_project_code_per_company'),) + def __repr__(self): return f'' @@ -58,7 +97,11 @@ class Project(db.Model): if not self.is_active: return False - # Admins and Supervisors can log time to any project + # Must be in same company + if self.company_id != user.company_id: + return False + + # Admins and Supervisors can log time to any project in their company if user.role in [Role.ADMIN, Role.SUPERVISOR]: return True @@ -66,18 +109,20 @@ class Project(db.Model): if self.team_id: return user.team_id == self.team_id - # If no team restriction, any user can log time + # If no team restriction, any user in the company can log time return True # Update User model to include role and team relationship class User(db.Model): id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(80), unique=True, nullable=False) - email = db.Column(db.String(120), unique=True, nullable=False) + username = db.Column(db.String(80), nullable=False) + email = db.Column(db.String(120), nullable=False) password_hash = db.Column(db.String(128)) - is_admin = db.Column(db.Boolean, default=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) + # Company association for multi-tenancy + company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False) + # Email verification fields is_verified = db.Column(db.Boolean, default=False) verification_token = db.Column(db.String(100), unique=True, nullable=True) @@ -90,6 +135,12 @@ class User(db.Model): role = db.Column(db.Enum(Role), default=Role.TEAM_MEMBER) team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True) + # Unique constraints per company + __table_args__ = ( + db.UniqueConstraint('company_id', 'username', name='uq_user_username_per_company'), + db.UniqueConstraint('company_id', 'email', name='uq_user_email_per_company'), + ) + # Two-Factor Authentication fields two_factor_enabled = db.Column(db.Boolean, default=False) two_factor_secret = db.Column(db.String(32), nullable=True) # Base32 encoded secret diff --git a/repair_roles.py b/repair_roles.py index 01d229a..8bec4ae 100644 --- a/repair_roles.py +++ b/repair_roles.py @@ -32,7 +32,7 @@ def repair_user_roles(): user.role = role_mapping.get(user.role, Role.TEAM_MEMBER) fixed_count += 1 elif user.role is None: - user.role = Role.ADMIN if user.is_admin else Role.TEAM_MEMBER + user.role = Role.TEAM_MEMBER fixed_count += 1 if fixed_count > 0: diff --git a/templates/admin_company.html b/templates/admin_company.html new file mode 100644 index 0000000..b9eb18e --- /dev/null +++ b/templates/admin_company.html @@ -0,0 +1,213 @@ +{% extends "layout.html" %} + +{% block content %} +
+
+

Company Management

+ +
+ + +
+

Company Information

+
+
+
+

{{ company.name }}

+ + {{ 'Active' if company.is_active else 'Inactive' }} + +
+
+
+ Company Code: + {{ company.slug }} +
+
+ Created: + {{ company.created_at.strftime('%Y-%m-%d %H:%M') }} +
+
+ Max Users: + {{ company.max_users or 'Unlimited' }} +
+ {% if company.description %} +
+ Description: + {{ company.description }} +
+ {% endif %} +
+
+
+
+ + +
+

Company Statistics

+
+
+

{{ stats.total_users }}

+

Total Users

+
+
+

{{ stats.total_teams }}

+

Teams

+
+
+

{{ stats.total_projects }}

+

Total Projects

+
+
+

{{ stats.active_projects }}

+

Active Projects

+
+
+
+ + +
+

Management

+
+
+

Users

+

Manage user accounts, roles, and permissions within your company.

+ Manage Users +
+ +
+

Teams

+

Create and manage teams to organize your company structure.

+ Manage Teams +
+ +
+

Projects

+

Set up and manage projects for time tracking and organization.

+ Manage Projects +
+ +
+

Settings

+

Configure system-wide settings and preferences.

+ System Settings +
+
+
+ + +
+

User Registration

+
+

Share this company code with new users for registration:

+
+ + +
+

New users will need this code when registering for your company.

+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_users.html b/templates/admin_users.html index 58636cd..cf38822 100644 --- a/templates/admin_users.html +++ b/templates/admin_users.html @@ -24,7 +24,7 @@ {{ user.username }} {{ user.email }} - {% if user.is_admin %}Admin{% else %}User{% endif %} + {{ user.role.value if user.role else 'Team Member' }} {% if user.is_blocked %}Blocked{% else %}Active{% endif %} diff --git a/templates/company_users.html b/templates/company_users.html new file mode 100644 index 0000000..41c6013 --- /dev/null +++ b/templates/company_users.html @@ -0,0 +1,202 @@ +{% extends "layout.html" %} + +{% block content %} +
+
+

Company Users - {{ company.name }}

+ Create New User +
+ + +
+

User Statistics

+
+
+

{{ stats.total }}

+

Total Users

+
+
+

{{ stats.active }}

+

Active Users

+
+
+

{{ stats.unverified }}

+

Unverified

+
+
+

{{ stats.blocked }}

+

Blocked

+
+
+

{{ stats.admins }}

+

Administrators

+
+
+

{{ stats.supervisors }}

+

Supervisors

+
+
+
+ + +
+

User List

+ {% if users %} +
+ + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + {% endfor %} + +
UsernameEmailRoleTeamStatusCreatedActions
+ {{ user.username }} + {% if user.two_factor_enabled %} + 🔒 + {% endif %} + {{ user.email }} + + {{ user.role.value }} + + + {% if user.team %} + {{ user.team.name }} + {% else %} + No team + {% endif %} + + + {% if user.is_blocked %}Blocked{% elif not user.is_verified %}Unverified{% else %}Active{% endif %} + + {{ user.created_at.strftime('%Y-%m-%d') }} + Edit + {% if user.id != g.user.id %} + {% if user.is_blocked %} + Unblock + {% else %} + Block + {% endif %} + + {% endif %} +
+
+ {% else %} +
+

No Users Found

+

There are no users in this company yet.

+ Add First User +
+ {% endif %} +
+ + + +
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/create_user.html b/templates/create_user.html index ac04403..3cc49d8 100644 --- a/templates/create_user.html +++ b/templates/create_user.html @@ -21,10 +21,22 @@
- + + +
+ +
+ +
diff --git a/templates/dashboard.html b/templates/dashboard.html index 81bd4e5..8b3bab2 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -3,7 +3,7 @@ {% block content %}

- {% if g.user.is_admin or g.user.role == Role.ADMIN %} + {% if g.user.role == Role.ADMIN %} Admin Dashboard {% elif g.user.role == Role.SUPERVISOR %} Supervisor Dashboard @@ -39,7 +39,7 @@

- {% if g.user.is_admin or g.user.role == Role.ADMIN %} + {% if g.user.role == Role.ADMIN %}

System Overview

@@ -90,7 +90,7 @@ {% endif %} - {% if g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] or g.user.is_admin %} + {% if g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN] %}

Team Management

@@ -113,7 +113,7 @@ View Team Hours
- {% if g.user.is_admin %} + {% if g.user.role == Role.ADMIN %}

Team Configuration

Create and manage team structures.

@@ -161,7 +161,7 @@ - {% if g.user.is_admin or g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %} + {% if g.user.role in [Role.ADMIN, Role.TEAM_LEADER, Role.SUPERVISOR] %} {% endif %} @@ -174,7 +174,7 @@ {% for entry in recent_entries %} - {% if g.user.is_admin or g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %} + {% if g.user.role in [Role.ADMIN, Role.TEAM_LEADER, Role.SUPERVISOR] %} {% endif %} diff --git a/templates/edit_company.html b/templates/edit_company.html new file mode 100644 index 0000000..ed36e99 --- /dev/null +++ b/templates/edit_company.html @@ -0,0 +1,73 @@ +{% extends "layout.html" %} + +{% block content %} +
+

Edit Company

+ +
+
+ + +
+ +
+ + + Optional description of your company +
+ +
+ + + Leave empty for unlimited users +
+ +
+ + Inactive companies cannot be accessed by users +
+ +
+

Company Code

+

{{ company.slug }}

+

This code cannot be changed and is used by new users to register for your company.

+
+ +
+ Cancel + +
+ +
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/edit_user.html b/templates/edit_user.html index f031d30..7c29c00 100644 --- a/templates/edit_user.html +++ b/templates/edit_user.html @@ -43,12 +43,6 @@ -
- -
diff --git a/templates/layout.html b/templates/layout.html index d6e8137..a27d7e6 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -3,7 +3,7 @@ - {{ title }} - TimeTrack + {{ title }} - TimeTrack{% if g.company %} - {{ g.company.name }}{% endif %} @@ -11,6 +11,9 @@
TimeTrack + {% if g.company %} + {{ g.company.name }} + {% endif %}
+
+ + + {% if is_initial_setup and existing_companies > 0 %} +
+

Already have an account?

+ Go to Login +
+ {% endif %} + {% endif %} + + + + + +{% endblock %} \ No newline at end of file
UserDate
{{ entry.user.username }}{{ entry.arrival_time.strftime('%Y-%m-%d') }}