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..b6963ac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +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 + +VOLUME /data +RUN mkdir /data && chmod 777 /data + +# Expose the port the app runs on +EXPOSE 5000 + +# 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 diff --git a/README.md b/README.md index 7e1d533..9af5d3c 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 @@ -139,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 46bb7e2..513cc57 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 from data_formatting import ( format_duration, prepare_export_data, prepare_team_hours_export_data, format_table_data, format_graph_data, format_team_data @@ -28,7 +28,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 @@ -53,22 +53,439 @@ 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.") + with app.app_context(): + 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: + # 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()] + + 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) + + # 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(): + 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, + 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, + company_id INTEGER, + FOREIGN KEY (created_by_id) REFERENCES user (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 + ) + """) + + # 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 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(): - # 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() + + 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() -# Call this function during app initialization (add it where you initialize the app) +def migrate_data(): + """Handle data migrations and setup""" + # 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 + ) + 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.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 def initialize_app(): - init_system_settings() + run_migrations() # Add this after initializing the app but before defining routes @app.context_processor @@ -92,12 +509,17 @@ 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) 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): """ @@ -111,7 +533,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 @@ -130,19 +552,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(): @@ -162,12 +617,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, @@ -202,7 +661,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() @@ -222,7 +681,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')) @@ -240,18 +699,23 @@ 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') 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 @@ -263,26 +727,67 @@ 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: - new_user = User(username=username, email=email, is_verified=False) + # 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, + company_id=company.id, + is_verified=False + ) new_user.set_password(password) - # Generate verification token + # 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 in company + elif not email_verification_required: + # If email verification is disabled, auto-verify new users + new_user.is_verified = True + + # Generate verification token (even if not needed, for consistency) + 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_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 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 + 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: @@ -295,10 +800,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() @@ -309,6 +814,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() @@ -331,27 +955,36 @@ 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, @@ -360,7 +993,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: @@ -378,18 +1011,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 @@ -404,10 +1038,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 @@ -420,7 +1054,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 @@ -456,22 +1090,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') @@ -483,15 +1117,13 @@ 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: @@ -511,16 +1143,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'): @@ -668,7 +1301,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')) @@ -823,25 +1456,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 def delete_entry(entry_id): @@ -890,8 +1504,6 @@ def update_entry(entry_id): } }) - - def calculate_work_duration(arrival_time, departure_time, total_break_duration): """ Calculate work duration considering both configured and actual break times. @@ -983,8 +1595,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'): @@ -1009,30 +1622,125 @@ 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) +# 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') @@ -1042,11 +1750,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() @@ -1059,8 +1767,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') @@ -1070,8 +1779,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 @@ -1087,8 +1796,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: @@ -1104,8 +1814,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') @@ -1119,8 +1830,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 @@ -1161,8 +1872,10 @@ 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() @@ -1177,12 +1890,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') @@ -1198,8 +1913,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 @@ -1224,6 +1939,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, @@ -1236,14 +1952,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') @@ -1260,8 +1977,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 @@ -1295,14 +2012,16 @@ 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() @@ -1320,6 +2039,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) @@ -1460,7 +2180,6 @@ def get_date_range(period, start_date_str=None, end_date_str=None): except (ValueError, TypeError): raise ValueError('Invalid date format') - @app.route('/download_export') def download_export(): """Handle export download requests.""" @@ -1669,6 +2388,6 @@ def analytics_export(): flash('Error generating export', 'error') return redirect(url_for('analytics')) - 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..a46b6e7 --- /dev/null +++ b/fly.toml @@ -0,0 +1,34 @@ +# 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] + +[http_service] + internal_port = 5000 + force_https = true + auto_stop_machines = 'stop' + auto_start_machines = true + 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" + + +[mounts] + source = "timetrack_data" + destination = "/data" + +[[vm]] + cpu_kind = 'shared' + cpus = 1 + memory_mb = 256 diff --git a/migrate_db.py b/migrate_db.py index af61e0b..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 @@ -316,6 +312,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/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_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. +

+
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 2c75ab6..177a5be 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

@@ -107,8 +107,7 @@
- - {% if g.user.is_admin %} + {% if g.user.role == Role.ADMIN %}

Team Configuration

Create and manage team structures.

@@ -156,7 +155,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 %} @@ -169,7 +168,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 a9fc498..28b0eec 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') }}