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/ Total Users Teams Total Projects Active Projects Share this company code with new users for registration: New users will need this code when registering for your company.Company Management
+
+ Company Information
+ {{ company.name }}
+
+ {{ 'Active' if company.is_active else 'Inactive' }}
+
+ Company Statistics
+ {{ stats.total_users }}
+ {{ stats.total_teams }}
+ {{ stats.total_projects }}
+ {{ stats.active_projects }}
+ Management
+ User Registration
+
+ When enabled, new users must verify their email address before accessing the application. When disabled, new users can log in immediately after registration. +
+Total Users
+Active Users
+Unverified
+Blocked
+Administrators
+Supervisors
+| Username | +Role | +Team | +Status | +Created | +Actions | +|
|---|---|---|---|---|---|---|
| + {{ 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 %} + | +
Create and manage team structures.
@@ -156,7 +155,7 @@| User | {% endif %}Date | @@ -169,7 +168,7 @@
|---|---|
| {{ entry.user.username }} | {% endif %}{{ entry.arrival_time.strftime('%Y-%m-%d') }} | 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 %} +