Improve db migrations.

Move all migration code to python module and use it from app.py. Use Enum values to avoid problems with Enum names in DB.
This commit is contained in:
2025-07-03 12:04:03 +02:00
committed by Jens Luedicke
parent 387243d516
commit d4e56c5cde
7 changed files with 1440 additions and 1445 deletions

View File

@@ -91,12 +91,6 @@ The integrated migration system handles:
- 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
The application can be configured through:

1353
app.py

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,172 +0,0 @@
#!/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

@@ -1,182 +0,0 @@
from app import app, db
from models import User, TimeEntry, Project, Team, Role
from sqlalchemy import text
import logging
from datetime import datetime
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def migrate_projects():
"""Migration script to add project time logging functionality"""
with app.app_context():
logger.info("Starting migration for project time logging...")
# Check if the project table exists
try:
# Create the project table if it doesn't exist
db.engine.execute(text("""
CREATE TABLE IF NOT EXISTS project (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
description TEXT,
code VARCHAR(20) NOT NULL UNIQUE,
is_active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL,
team_id INTEGER,
start_date DATE,
end_date DATE,
FOREIGN KEY (created_by_id) REFERENCES user (id),
FOREIGN KEY (team_id) REFERENCES team (id)
)
"""))
logger.info("Project table created or already exists")
except Exception as e:
logger.error(f"Error creating project table: {e}")
return
# Check if the time_entry table has the project-related columns
try:
# Check if project_id and notes columns exist in time_entry
result = db.engine.execute(text("PRAGMA table_info(time_entry)"))
columns = [row[1] for row in result]
if 'project_id' not in columns:
db.engine.execute(text("ALTER TABLE time_entry ADD COLUMN project_id INTEGER REFERENCES project(id)"))
logger.info("Added project_id column to time_entry table")
if 'notes' not in columns:
db.engine.execute(text("ALTER TABLE time_entry ADD COLUMN notes TEXT"))
logger.info("Added notes column to time_entry table")
except Exception as e:
logger.error(f"Error updating time_entry table: {e}")
return
# Create some default projects for demonstration
try:
# Check if any projects exist
existing_projects = Project.query.count()
if existing_projects == 0:
# Find an admin or supervisor user to be the creator
admin_user = User.query.filter(User.role.in_([Role.ADMIN, Role.SUPERVISOR])).first()
if admin_user:
# Create some sample projects
sample_projects = [
{
'name': 'General Administration',
'code': 'ADMIN001',
'description': 'General administrative tasks and meetings',
'team_id': None, # Available to all teams
},
{
'name': 'Development Project',
'code': 'DEV001',
'description': 'Software development and maintenance tasks',
'team_id': None, # Available to all teams
},
{
'name': 'Customer Support',
'code': 'SUPPORT001',
'description': 'Customer service and technical support activities',
'team_id': None, # Available to all teams
}
]
for proj_data in sample_projects:
project = Project(
name=proj_data['name'],
code=proj_data['code'],
description=proj_data['description'],
team_id=proj_data['team_id'],
created_by_id=admin_user.id,
is_active=True
)
db.session.add(project)
db.session.commit()
logger.info(f"Created {len(sample_projects)} sample projects")
else:
logger.warning("No admin or supervisor user found to create sample projects")
else:
logger.info(f"Found {existing_projects} existing projects, skipping sample creation")
except Exception as e:
logger.error(f"Error creating sample projects: {e}")
db.session.rollback()
# Update database schema to match the current models
try:
db.create_all()
logger.info("Database schema updated successfully")
except Exception as e:
logger.error(f"Error updating database schema: {e}")
return
# Verify the migration
try:
# Check if we can query the new tables and columns
project_count = Project.query.count()
logger.info(f"Project table accessible with {project_count} projects")
# Check if time_entry has the new columns
result = db.engine.execute(text("PRAGMA table_info(time_entry)"))
columns = [row[1] for row in result]
required_columns = ['project_id', 'notes']
missing_columns = [col for col in required_columns if col not in columns]
if missing_columns:
logger.error(f"Missing columns in time_entry: {missing_columns}")
return
else:
logger.info("All required columns present in time_entry table")
except Exception as e:
logger.error(f"Error verifying migration: {e}")
return
logger.info("Project time logging migration completed successfully!")
print("\n" + "="*60)
print("PROJECT TIME LOGGING FEATURE ENABLED")
print("="*60)
print("✅ Project management interface available for Admins/Supervisors")
print("✅ Time tracking with optional project selection")
print("✅ Project-based reporting and filtering")
print("✅ Enhanced export functionality with project data")
print("\nAccess project management via:")
print("- Admin dropdown → Manage Projects")
print("- Supervisor dropdown → Manage Projects")
print("="*60)
def rollback_projects():
"""Rollback migration (removes project functionality)"""
with app.app_context():
logger.warning("Rolling back project time logging migration...")
try:
# Drop the project table
db.engine.execute(text("DROP TABLE IF EXISTS project"))
logger.info("Dropped project table")
# Note: SQLite doesn't support dropping columns, so we can't remove
# project_id and notes columns from time_entry table
logger.warning("Note: project_id and notes columns in time_entry table cannot be removed due to SQLite limitations")
logger.warning("These columns will remain but will not be used")
except Exception as e:
logger.error(f"Error during rollback: {e}")
return
logger.info("Project time logging rollback completed")
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "rollback":
rollback_projects()
else:
migrate_projects()

View File

@@ -1,89 +0,0 @@
from app import app, db
from models import User, Team, Role, SystemSettings
from sqlalchemy import text
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def migrate_roles_teams():
with app.app_context():
logger.info("Starting migration for roles and teams...")
# Check if the team table exists
try:
# Create the team table if it doesn't exist
db.engine.execute(text("""
CREATE TABLE IF NOT EXISTS team (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL UNIQUE,
description VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""))
logger.info("Team table created or already exists")
except Exception as e:
logger.error(f"Error creating team table: {e}")
return
# Check if the user table has the role and team_id columns
try:
# Check if role column exists
result = db.engine.execute(text("PRAGMA table_info(user)"))
columns = [row[1] for row in result]
if 'role' not in columns:
# Use the enum name instead of the value
db.engine.execute(text("ALTER TABLE user ADD COLUMN role VARCHAR(20) DEFAULT 'TEAM_MEMBER'"))
logger.info("Added role column to user table")
if 'team_id' not in columns:
db.engine.execute(text("ALTER TABLE user ADD COLUMN team_id INTEGER REFERENCES team(id)"))
logger.info("Added team_id column to user table")
# Create a default team for existing users
default_team = Team.query.filter_by(name="Default Team").first()
if not default_team:
default_team = Team(name="Default Team", description="Default team for existing users")
db.session.add(default_team)
db.session.commit()
logger.info("Created default team")
# Map string role values to enum values
role_mapping = {
'Team Member': Role.TEAM_MEMBER,
'TEAM_MEMBER': Role.TEAM_MEMBER,
'Team Leader': Role.TEAM_LEADER,
'TEAM_LEADER': Role.TEAM_LEADER,
'Supervisor': Role.SUPERVISOR,
'SUPERVISOR': Role.SUPERVISOR,
'Administrator': Role.ADMIN,
'admin': Role.ADMIN,
'ADMIN': Role.ADMIN
}
# Assign all existing users to the default team and set role based on admin status
users = User.query.all()
for user in users:
if user.team_id is None:
user.team_id = default_team.id
# Handle role conversion properly
if isinstance(user.role, str):
# 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
user.role = Role.TEAM_MEMBER
db.session.commit()
logger.info(f"Assigned {len(users)} existing users to default team and updated roles")
except Exception as e:
logger.error(f"Error updating user table: {e}")
return
logger.info("Migration completed successfully")
if __name__ == "__main__":
migrate_roles_teams()

151
models.py
View File

@@ -86,6 +86,9 @@ class Project(db.Model):
# Optional team assignment - if set, only team members can log time to this project
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True)
# Project categorization
category_id = db.Column(db.Integer, db.ForeignKey('project_category.id'), nullable=True)
# Project dates
start_date = db.Column(db.Date, nullable=True)
end_date = db.Column(db.Date, nullable=True)
@@ -94,6 +97,7 @@ class Project(db.Model):
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='created_projects')
team = db.relationship('Team', backref='projects')
time_entries = db.relationship('TimeEntry', backref='project', lazy=True)
category = db.relationship('ProjectCategory', back_populates='projects')
# Unique constraint per company
__table_args__ = (db.UniqueConstraint('company_id', 'code', name='uq_project_code_per_company'),)
@@ -237,6 +241,10 @@ class TimeEntry(db.Model):
# Project association - nullable for backward compatibility
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True)
# Task/SubTask associations - nullable for backward compatibility
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True)
subtask_id = db.Column(db.Integer, db.ForeignKey('sub_task.id'), nullable=True)
# Optional notes/description for the time entry
notes = db.Column(db.Text, nullable=True)
@@ -378,4 +386,145 @@ class UserPreferences(db.Model):
__table_args__ = (db.UniqueConstraint('user_id', name='uq_user_preferences'),)
def __repr__(self):
return f'<UserPreferences {self.user.username}: {self.date_format}, {"24h" if self.time_format_24h else "12h"}>'
return f'<UserPreferences {self.user.username}: {self.date_format}, {"24h" if self.time_format_24h else "12h"}>'
# Project Category model for organizing projects
class ProjectCategory(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)
color = db.Column(db.String(7), default='#007bff') # Hex color for UI
icon = db.Column(db.String(50), nullable=True) # Icon name/emoji
# Company association for multi-tenancy
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
company = db.relationship('Company', backref='project_categories')
created_by = db.relationship('User', foreign_keys=[created_by_id])
projects = db.relationship('Project', back_populates='category', lazy=True)
# Unique constraint per company
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_category_name_per_company'),)
def __repr__(self):
return f'<ProjectCategory {self.name}>'
# Task status enumeration
class TaskStatus(enum.Enum):
NOT_STARTED = "Not Started"
IN_PROGRESS = "In Progress"
ON_HOLD = "On Hold"
COMPLETED = "Completed"
CANCELLED = "Cancelled"
# Task priority enumeration
class TaskPriority(enum.Enum):
LOW = "Low"
MEDIUM = "Medium"
HIGH = "High"
URGENT = "Urgent"
# Task model for project breakdown
class Task(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
# Task properties
status = db.Column(db.Enum(TaskStatus), default=TaskStatus.NOT_STARTED)
priority = db.Column(db.Enum(TaskPriority), default=TaskPriority.MEDIUM)
estimated_hours = db.Column(db.Float, nullable=True) # Estimated time to complete
# Project association
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
# Task assignment
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
# Task dates
start_date = db.Column(db.Date, nullable=True)
due_date = db.Column(db.Date, nullable=True)
completed_date = db.Column(db.Date, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
project = db.relationship('Project', backref='tasks')
assigned_to = db.relationship('User', foreign_keys=[assigned_to_id], backref='assigned_tasks')
created_by = db.relationship('User', foreign_keys=[created_by_id])
subtasks = db.relationship('SubTask', backref='parent_task', lazy=True, cascade='all, delete-orphan')
time_entries = db.relationship('TimeEntry', backref='task', lazy=True)
def __repr__(self):
return f'<Task {self.name} ({self.status.value})>'
@property
def progress_percentage(self):
"""Calculate task progress based on subtasks completion"""
if not self.subtasks:
return 100 if self.status == TaskStatus.COMPLETED else 0
completed_subtasks = sum(1 for subtask in self.subtasks if subtask.status == TaskStatus.COMPLETED)
return int((completed_subtasks / len(self.subtasks)) * 100)
@property
def total_time_logged(self):
"""Calculate total time logged to this task (in seconds)"""
return sum(entry.duration or 0 for entry in self.time_entries if entry.duration)
def can_user_access(self, user):
"""Check if a user can access this task"""
return self.project.is_user_allowed(user)
# SubTask model for task breakdown
class SubTask(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
# SubTask properties
status = db.Column(db.Enum(TaskStatus), default=TaskStatus.NOT_STARTED)
priority = db.Column(db.Enum(TaskPriority), default=TaskPriority.MEDIUM)
estimated_hours = db.Column(db.Float, nullable=True)
# Parent task association
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
# Assignment
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
# Dates
start_date = db.Column(db.Date, nullable=True)
due_date = db.Column(db.Date, nullable=True)
completed_date = db.Column(db.Date, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
assigned_to = db.relationship('User', foreign_keys=[assigned_to_id], backref='assigned_subtasks')
created_by = db.relationship('User', foreign_keys=[created_by_id])
time_entries = db.relationship('TimeEntry', backref='subtask', lazy=True)
def __repr__(self):
return f'<SubTask {self.name} ({self.status.value})>'
@property
def total_time_logged(self):
"""Calculate total time logged to this subtask (in seconds)"""
return sum(entry.duration or 0 for entry in self.time_entries if entry.duration)
def can_user_access(self, user):
"""Check if a user can access this subtask"""
return self.parent_task.can_user_access(user)