diff --git a/MIGRATION_FREELANCERS.md b/MIGRATION_FREELANCERS.md new file mode 100644 index 0000000..cd33a58 --- /dev/null +++ b/MIGRATION_FREELANCERS.md @@ -0,0 +1,180 @@ +# Freelancer Migration Guide + +This document explains the database migration for freelancer support in TimeTrack. + +## Overview + +The freelancer migration adds support for independent users who can register without a company token. It introduces: + +1. **Account Types**: Users can be either "Company User" or "Freelancer" +2. **Personal Companies**: Freelancers automatically get their own company workspace +3. **Business Names**: Optional field for freelancers to specify their business name + +## Database Changes + +### User Table Changes +- `account_type` VARCHAR(20) DEFAULT 'Company User' - Type of account +- `business_name` VARCHAR(100) - Optional business name for freelancers +- `company_id` INTEGER - Foreign key to company table (for multi-tenancy) + +### Company Table Changes +- `is_personal` BOOLEAN DEFAULT 0 - Marks companies auto-created for freelancers + +## Migration Options + +### Option 1: Automatic Migration (Recommended) +The main migration script (`migrate_db.py`) now includes freelancer support: + +```bash +python migrate_db.py +``` + +This will: +- Add new columns to existing tables +- Create company table if it doesn't exist +- Set default values for existing users + +### Option 2: Dedicated Freelancer Migration +Use the dedicated freelancer migration script: + +```bash +python migrate_freelancers.py +``` + +### Option 3: Manual SQL Migration +If you prefer manual control: + +```sql +-- Add columns to user table +ALTER TABLE user ADD COLUMN account_type VARCHAR(20) DEFAULT 'Company User'; +ALTER TABLE user ADD COLUMN business_name VARCHAR(100); +ALTER TABLE user ADD COLUMN company_id INTEGER; + +-- Create company table (if it doesn't exist) +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_personal BOOLEAN DEFAULT 0, + is_active BOOLEAN DEFAULT 1, + max_users INTEGER DEFAULT 100 +); + +-- Or add column to existing company table +ALTER TABLE company ADD COLUMN is_personal BOOLEAN DEFAULT 0; + +-- Update existing users +UPDATE user SET account_type = 'Company User' WHERE account_type IS NULL; +``` + +## Post-Migration Steps + +### For Existing Installations +1. **Create Default Company**: If you have existing users without a company, create one: + ```python + # In Python/Flask shell + from models import db, Company, User + + # Create default company + company = Company( + name="Default Company", + slug="default-company", + description="Default company for existing users" + ) + db.session.add(company) + db.session.flush() + + # Assign existing users to default company + User.query.filter_by(company_id=None).update({'company_id': company.id}) + db.session.commit() + ``` + +2. **Verify Migration**: Check that all users have a company_id: + ```sql + SELECT COUNT(*) FROM user WHERE company_id IS NULL; + -- Should return 0 + ``` + +### Testing Freelancer Registration +1. Visit `/register/freelancer` +2. Register a new freelancer account +3. Verify the personal company was created +4. Test login and time tracking functionality + +## New Features Available + +### Freelancer Registration +- **URL**: `/register/freelancer` +- **Features**: + - No company token required + - Auto-creates personal workspace + - Optional business name field + - Immediate account activation + +### Registration Options +- **Company Registration**: `/register` (existing) +- **Freelancer Registration**: `/register/freelancer` (new) +- **Login Page**: Shows both registration options + +### User Experience +- Freelancers get admin privileges in their personal company +- Can create projects and track time immediately +- Personal workspace is limited to 1 user by default +- Can optionally expand to hire employees later + +## Troubleshooting + +### Common Issues + +**Migration fails with "column already exists"** +- This is normal if you've run the migration before +- The migration script checks for existing columns + +**Users missing company_id after migration** +- Run the post-migration steps above to assign a default company + +**Freelancer registration fails** +- Check that the AccountType enum is imported correctly +- Verify database migration completed successfully + +### Rollback (Limited) +SQLite doesn't support dropping columns, so rollback is limited: + +```bash +python migrate_freelancers.py rollback +``` + +For full rollback, you would need to: +1. Export user data +2. Recreate tables without freelancer columns +3. Re-import data + +## Verification Commands + +```bash +# Verify migration applied +python migrate_freelancers.py verify + +# Check table structure +sqlite3 timetrack.db ".schema user" +sqlite3 timetrack.db ".schema company" + +# Check data +sqlite3 timetrack.db "SELECT account_type, COUNT(*) FROM user GROUP BY account_type;" +``` + +## Security Considerations + +- Freelancers get unique usernames/emails globally (not per-company) +- Personal companies are limited to 1 user by default +- Freelancers have admin privileges only in their personal workspace +- Multi-tenant isolation is maintained + +## Future Enhancements + +- Allow freelancers to upgrade to team accounts +- Billing integration for freelancer vs company accounts +- Advanced freelancer-specific features +- Integration with invoicing systems \ No newline at end of file diff --git a/app.py b/app.py index 4ab7735..4847094 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, Company, CompanyWorkConfig, UserPreferences, WorkRegion +from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType from data_formatting import ( format_duration, prepare_export_data, prepare_team_hours_export_data, format_table_data, format_graph_data, format_team_data @@ -159,7 +159,9 @@ def run_migrations(): ('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)") + ('two_factor_secret', "ALTER TABLE user ADD COLUMN two_factor_secret VARCHAR(32)"), + ('account_type', "ALTER TABLE user ADD COLUMN account_type VARCHAR(20) DEFAULT 'Company User'"), + ('business_name', "ALTER TABLE user ADD COLUMN business_name VARCHAR(100)") ] for column_name, sql_command in user_migrations: @@ -193,7 +195,10 @@ def run_migrations(): role VARCHAR(50) DEFAULT 'Team Member', team_id INTEGER, two_factor_enabled BOOLEAN DEFAULT 0, - two_factor_secret VARCHAR(32) + two_factor_secret VARCHAR(32), + account_type VARCHAR(20) DEFAULT 'Company User', + business_name VARCHAR(100), + company_id INTEGER ) """) @@ -201,10 +206,13 @@ def run_migrations(): 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) + two_factor_enabled, two_factor_secret, account_type, business_name, company_id) 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 + two_factor_enabled, two_factor_secret, + COALESCE(account_type, 'Company User'), + business_name, + company_id FROM user """) @@ -276,10 +284,24 @@ def run_migrations(): slug VARCHAR(50) UNIQUE NOT NULL, description TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_personal BOOLEAN DEFAULT 0, is_active BOOLEAN DEFAULT 1, max_users INTEGER DEFAULT 100 ) """) + else: + # Check and add missing columns to existing company table + cursor.execute("PRAGMA table_info(company)") + company_columns = [column[1] for column in cursor.fetchall()] + + company_migrations = [ + ('is_personal', "ALTER TABLE company ADD COLUMN is_personal BOOLEAN DEFAULT 0") + ] + + for column_name, sql_command in company_migrations: + if column_name not in company_columns: + print(f"Adding {column_name} column to company...") + cursor.execute(sql_command) # Create company_work_config table if it doesn't exist cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='company_work_config'") @@ -963,6 +985,100 @@ The TimeTrack Team return render_template('register.html', title='Register') +@app.route('/register/freelancer', methods=['GET', 'POST']) +def register_freelancer(): + """Freelancer registration route - creates user without company token""" + # Check if registration is enabled + 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')) + + 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') + business_name = request.form.get('business_name', '').strip() + + # Validate input + error = None + if not username: + error = 'Username is required' + elif not email: + error = 'Email is required' + elif not password: + error = 'Password is required' + elif password != confirm_password: + error = 'Passwords do not match' + + # Check for existing users globally (freelancers get unique usernames/emails) + if not error: + if User.query.filter_by(username=username).first(): + error = 'Username already exists' + elif User.query.filter_by(email=email).first(): + error = 'Email already registered' + + if error is None: + try: + # Create personal company for freelancer + company_name = business_name if business_name else f"{username}'s Workspace" + + # Generate unique 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 personal company + personal_company = Company( + name=company_name, + slug=slug, + description=f"Personal workspace for {username}", + is_personal=True, + max_users=1 # Limit to single user + ) + + db.session.add(personal_company) + db.session.flush() # Get company ID + + # Create freelancer user + new_user = User( + username=username, + email=email, + company_id=personal_company.id, + account_type=AccountType.FREELANCER, + business_name=business_name if business_name else None, + role=Role.ADMIN, # Freelancers are admins of their personal company + is_verified=True # Auto-verify freelancers + ) + new_user.set_password(password) + + db.session.add(new_user) + db.session.commit() + + logger.info(f"Freelancer account created: {username} with personal company: {company_name}") + flash(f'Welcome {username}! Your freelancer account has been created successfully. You can now log in.', 'success') + + return redirect(url_for('login')) + + except Exception as e: + db.session.rollback() + logger.error(f"Error during freelancer registration: {str(e)}") + error = f"An error occurred during registration: {str(e)}" + + if error: + flash(error, 'error') + + return render_template('register_freelancer.html', title='Register as Freelancer') + @app.route('/setup_company', methods=['GET', 'POST']) def setup_company(): """Company setup route for creating new companies with admin users""" diff --git a/migrate_db.py b/migrate_db.py index 27ccba9..937decf 100644 --- a/migrate_db.py +++ b/migrate_db.py @@ -1,7 +1,7 @@ from app import app, db import sqlite3 import os -from models import User, TimeEntry, WorkConfig, SystemSettings, Team, Role, Project +from models import User, TimeEntry, WorkConfig, SystemSettings, Team, Role, Project, Company, AccountType from werkzeug.security import generate_password_hash from datetime import datetime @@ -117,6 +117,21 @@ def migrate_database(): print("Adding team_id column to user table...") cursor.execute("ALTER TABLE user ADD COLUMN team_id INTEGER") + # Add freelancer support columns to user table + if 'account_type' not in user_columns: + print("Adding account_type column to user table...") + cursor.execute("ALTER TABLE user ADD COLUMN account_type VARCHAR(20) DEFAULT 'Company User'") + + if 'business_name' not in user_columns: + print("Adding business_name column to user table...") + cursor.execute("ALTER TABLE user ADD COLUMN business_name VARCHAR(100)") + + # Add company_id to user table for multi-tenancy + if 'company_id' not in user_columns: + print("Adding company_id column to user table...") + # Note: We can't add NOT NULL constraint to existing table, so allow NULL initially + cursor.execute("ALTER TABLE user ADD COLUMN company_id INTEGER") + # Add 2FA columns to user table if they don't exist if 'two_factor_enabled' not in user_columns: print("Adding two_factor_enabled column to user table...") @@ -175,6 +190,31 @@ def migrate_database(): ) """) + # Check if the company table exists + 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_personal BOOLEAN DEFAULT 0, + is_active BOOLEAN DEFAULT 1, + max_users INTEGER DEFAULT 100 + ) + """) + else: + # Check if company table has freelancer columns + cursor.execute("PRAGMA table_info(company)") + company_columns = [column[1] for column in cursor.fetchall()] + + if 'is_personal' not in company_columns: + print("Adding is_personal column to company table...") + cursor.execute("ALTER TABLE company ADD COLUMN is_personal BOOLEAN DEFAULT 0") + # Add project-related columns to time_entry table cursor.execute("PRAGMA table_info(time_entry)") time_entry_columns = [column[1] for column in cursor.fetchall()] diff --git a/migrate_freelancers.py b/migrate_freelancers.py new file mode 100644 index 0000000..180ff61 --- /dev/null +++ b/migrate_freelancers.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +""" +Migration script for freelancer support in TimeTrack. + +This migration adds: +1. AccountType enum support (handled by SQLAlchemy) +2. account_type column to user table +3. business_name column to user table +4. is_personal column to company table + +Usage: + python migrate_freelancers.py # Run migration + python migrate_freelancers.py rollback # Rollback migration +""" + +from app import app, db +import sqlite3 +import os +import sys +from models import User, Company, AccountType +from datetime import datetime + +def migrate_freelancer_support(): + """Add freelancer support to existing database""" + db_path = 'timetrack.db' + + # Check if database exists + if not os.path.exists(db_path): + print("Database doesn't exist. Please run main migration first.") + return False + + print("Migrating database for freelancer support...") + + # Connect to the database + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + # Check company table structure + cursor.execute("PRAGMA table_info(company)") + company_columns = [column[1] for column in cursor.fetchall()] + + # Add is_personal column to company table if it doesn't exist + if 'is_personal' not in company_columns: + print("Adding is_personal column to company table...") + cursor.execute("ALTER TABLE company ADD COLUMN is_personal BOOLEAN DEFAULT 0") + + # Check user table structure + cursor.execute("PRAGMA table_info(user)") + user_columns = [column[1] for column in cursor.fetchall()] + + # Add account_type column to user table if it doesn't exist + if 'account_type' not in user_columns: + print("Adding account_type column to user table...") + # Default to 'Company User' for existing users + cursor.execute("ALTER TABLE user ADD COLUMN account_type VARCHAR(20) DEFAULT 'Company User'") + + # Add business_name column to user table if it doesn't exist + if 'business_name' not in user_columns: + print("Adding business_name column to user table...") + cursor.execute("ALTER TABLE user ADD COLUMN business_name VARCHAR(100)") + + # Commit changes + conn.commit() + print("✓ Freelancer migration completed successfully!") + + # Update existing users to have explicit account_type + print("Updating existing users to Company User account type...") + cursor.execute("UPDATE user SET account_type = 'Company User' WHERE account_type IS NULL OR account_type = ''") + conn.commit() + + return True + + except Exception as e: + print(f"✗ Migration failed: {str(e)}") + conn.rollback() + return False + finally: + conn.close() + +def rollback_freelancer_support(): + """Rollback freelancer support migration""" + db_path = 'timetrack.db' + + if not os.path.exists(db_path): + print("Database doesn't exist.") + return False + + print("Rolling back freelancer support migration...") + + # Connect to the database + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + print("WARNING: SQLite doesn't support dropping columns directly.") + print("To fully rollback, you would need to:") + print("1. Create new tables without the freelancer columns") + print("2. Copy data from old tables to new tables") + print("3. Drop old tables and rename new ones") + print("\nFor safety, leaving columns in place but marking rollback as complete.") + print("The application will work without issues with the extra columns present.") + + return True + + except Exception as e: + print(f"✗ Rollback failed: {str(e)}") + return False + finally: + conn.close() + +def verify_migration(): + """Verify that the migration was applied correctly""" + db_path = 'timetrack.db' + + if not os.path.exists(db_path): + print("Database doesn't exist.") + return False + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + # Check company table + cursor.execute("PRAGMA table_info(company)") + company_columns = [column[1] for column in cursor.fetchall()] + + # Check user table + cursor.execute("PRAGMA table_info(user)") + user_columns = [column[1] for column in cursor.fetchall()] + + print("\n=== Migration Verification ===") + print("Company table columns:", company_columns) + print("User table columns:", user_columns) + + # Verify required columns exist + missing_columns = [] + if 'is_personal' not in company_columns: + missing_columns.append('company.is_personal') + if 'account_type' not in user_columns: + missing_columns.append('user.account_type') + if 'business_name' not in user_columns: + missing_columns.append('user.business_name') + + if missing_columns: + print(f"✗ Missing columns: {', '.join(missing_columns)}") + return False + else: + print("✓ All required columns present") + return True + + except Exception as e: + print(f"✗ Verification failed: {str(e)}") + return False + finally: + conn.close() + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == 'rollback': + success = rollback_freelancer_support() + elif len(sys.argv) > 1 and sys.argv[1] == 'verify': + success = verify_migration() + else: + success = migrate_freelancer_support() + if success: + verify_migration() + + if success: + print("\n✓ Operation completed successfully!") + else: + print("\n✗ Operation failed!") + sys.exit(1) \ No newline at end of file diff --git a/models.py b/models.py index 570c0be..0af1fa1 100644 --- a/models.py +++ b/models.py @@ -13,6 +13,11 @@ class Role(enum.Enum): SUPERVISOR = "Supervisor" ADMIN = "Administrator" # Keep existing admin role +# Define Account Type for freelancer support +class AccountType(enum.Enum): + COMPANY_USER = "Company User" + FREELANCER = "Freelancer" + # Company model for multi-tenancy class Company(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -21,6 +26,9 @@ class Company(db.Model): description = db.Column(db.Text) created_at = db.Column(db.DateTime, default=datetime.now) + # Freelancer support + is_personal = db.Column(db.Boolean, default=False) # True for auto-created freelancer companies + # Company settings is_active = db.Column(db.Boolean, default=True) max_users = db.Column(db.Integer, default=100) # Optional user limit @@ -135,6 +143,10 @@ 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) + # Freelancer support + account_type = db.Column(db.Enum(AccountType), default=AccountType.COMPANY_USER) + business_name = db.Column(db.String(100), nullable=True) # Optional business name for freelancers + # Unique constraints per company __table_args__ = ( db.UniqueConstraint('company_id', 'username', name='uq_user_username_per_company'), diff --git a/templates/login.html b/templates/login.html index 5656a69..ff30294 100644 --- a/templates/login.html +++ b/templates/login.html @@ -35,7 +35,8 @@
Don't have an account? Register here
+Don't have an account?
+Join your company team
{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} @@ -12,6 +13,14 @@ {% endif %} {% endwith %} + +