Add Freelancer registration model.

This commit is contained in:
Jens Luedicke
2025-07-02 23:05:30 +02:00
parent ff6d2da523
commit 8e100f101a
8 changed files with 602 additions and 7 deletions

180
MIGRATION_FREELANCERS.md Normal file
View File

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

126
app.py
View File

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

View File

@@ -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()]

172
migrate_freelancers.py Normal file
View File

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

View File

@@ -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'),

View File

@@ -35,7 +35,8 @@
</div>
<div class="auth-links">
<p>Don't have an account? <a href="{{ url_for('register') }}">Register here</a></p>
<p>Don't have an account?</p>
<p><a href="{{ url_for('register') }}">Register with Company Code</a> | <a href="{{ url_for('register_freelancer') }}">Register as Freelancer</a></p>
</div>
</form>
</div>

View File

@@ -3,6 +3,7 @@
{% block content %}
<div class="auth-container">
<h1>Register for TimeTrack</h1>
<p class="text-muted">Join your company team</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
@@ -12,6 +13,14 @@
{% endif %}
{% endwith %}
<div class="registration-options mb-4">
<div class="alert alert-info">
<h5>Registration Options:</h5>
<p><strong>Company Employee:</strong> You're on the right page! Enter your company code below.</p>
<p><strong>Freelancer/Independent:</strong> <a href="{{ url_for('register_freelancer') }}" class="btn btn-outline-primary btn-sm">Register as Freelancer</a></p>
</div>
</div>
<form method="POST" action="{{ url_for('register') }}" class="auth-form">
<div class="form-group">
<label for="company_code">Company Code</label>

View File

@@ -0,0 +1,65 @@
{% extends "layout.html" %}
{% block content %}
<div class="auth-container">
<h1>Register as Freelancer</h1>
<p class="text-muted">Create your independent freelancer account</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('register_freelancer') }}" class="auth-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" class="form-control" required autofocus>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" class="form-control" required>
</div>
<div class="form-group">
<label for="business_name">Business Name (Optional)</label>
<input type="text" id="business_name" name="business_name" class="form-control"
placeholder="Your business or freelance name">
<small class="form-text text-muted">Leave blank to use your username as your workspace name.</small>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" class="form-control" required>
</div>
<div class="form-group">
<label for="confirm_password">Confirm Password</label>
<input type="password" id="confirm_password" name="confirm_password" class="form-control" required>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Create Freelancer Account</button>
</div>
<div class="auth-links">
<p>Working for a company? <a href="{{ url_for('register') }}">Register with company code</a></p>
<p>Already have an account? <a href="{{ url_for('login') }}">Login here</a></p>
</div>
<div class="freelancer-info">
<h4>What you get as a freelancer:</h4>
<ul>
<li>Your own personal workspace</li>
<li>Time tracking for your projects</li>
<li>Project management tools</li>
<li>Export capabilities for invoicing</li>
<li>No company code required</li>
</ul>
</div>
</form>
</div>
{% endblock %}