Merge branch 'master' into feature-markdown-notes

This commit is contained in:
2025-07-07 21:26:44 +02:00
122 changed files with 23364 additions and 8651 deletions

View File

@@ -44,11 +44,11 @@ RUN mkdir -p /app/static/uploads/avatars && chmod -R 777 /app/static/uploads
VOLUME /data
RUN mkdir /data && chmod 777 /data
# Make startup script executable
RUN chmod +x startup.sh
# Make startup scripts executable
RUN chmod +x startup.sh startup_postgres.sh || true
# Expose the port the app runs on (though we'll use unix socket)
EXPOSE 5000
# Use startup script for automatic migration
CMD ["./startup.sh"]
# Use PostgreSQL-only startup script
CMD ["./startup_postgres.sh"]

176
SCHEMA_CHANGES_SUMMARY.md Normal file
View File

@@ -0,0 +1,176 @@
# Database Schema Changes Summary
This document summarizes all database schema changes between commit 4214e88 and the current state of the TimeTrack application.
## Architecture Changes
### 1. **Model Structure Refactoring**
- **Before**: Single monolithic `models.py` file containing all models
- **After**: Models split into domain-specific modules:
- `models/__init__.py` - Package initialization
- `models/base.py` - Base model definitions
- `models/company.py` - Company-related models
- `models/user.py` - User-related models
- `models/project.py` - Project-related models
- `models/task.py` - Task-related models
- `models/time_entry.py` - Time entry model
- `models/sprint.py` - Sprint model
- `models/team.py` - Team model
- `models/system.py` - System settings models
- `models/announcement.py` - Announcement model
- `models/dashboard.py` - Dashboard-related models
- `models/work_config.py` - Work configuration model
- `models/invitation.py` - Company invitation model
- `models/enums.py` - All enum definitions
## New Tables Added
### 1. **company_invitation** (NEW)
- Purpose: Email-based company registration invitations
- Columns:
- `id` (INTEGER, PRIMARY KEY)
- `company_id` (INTEGER, FOREIGN KEY → company.id)
- `email` (VARCHAR(120), NOT NULL)
- `token` (VARCHAR(64), UNIQUE, NOT NULL)
- `role` (VARCHAR(50), DEFAULT 'Team Member')
- `invited_by_id` (INTEGER, FOREIGN KEY → user.id)
- `created_at` (TIMESTAMP, DEFAULT CURRENT_TIMESTAMP)
- `expires_at` (TIMESTAMP, NOT NULL)
- `accepted` (BOOLEAN, DEFAULT FALSE)
- `accepted_at` (TIMESTAMP)
- `accepted_by_user_id` (INTEGER, FOREIGN KEY → user.id)
- Indexes:
- `idx_invitation_token` on token
- `idx_invitation_email` on email
- `idx_invitation_company` on company_id
- `idx_invitation_expires` on expires_at
## Modified Tables
### 1. **company**
- Added columns:
- `updated_at` (TIMESTAMP, DEFAULT CURRENT_TIMESTAMP) - NEW
### 2. **user**
- Added columns:
- `two_factor_enabled` (BOOLEAN, DEFAULT FALSE) - NEW
- `two_factor_secret` (VARCHAR(32), NULLABLE) - NEW
- `avatar_url` (VARCHAR(255), NULLABLE) - NEW
### 3. **user_preferences**
- Added columns:
- `theme` (VARCHAR(20), DEFAULT 'light')
- `language` (VARCHAR(10), DEFAULT 'en')
- `timezone` (VARCHAR(50), DEFAULT 'UTC')
- `date_format` (VARCHAR(20), DEFAULT 'YYYY-MM-DD')
- `time_format` (VARCHAR(10), DEFAULT '24h')
- `email_notifications` (BOOLEAN, DEFAULT TRUE)
- `email_daily_summary` (BOOLEAN, DEFAULT FALSE)
- `email_weekly_summary` (BOOLEAN, DEFAULT TRUE)
- `default_project_id` (INTEGER, FOREIGN KEY → project.id)
- `timer_reminder_enabled` (BOOLEAN, DEFAULT TRUE)
- `timer_reminder_interval` (INTEGER, DEFAULT 60)
- `dashboard_layout` (JSON, NULLABLE)
### 4. **user_dashboard**
- Added columns:
- `layout` (JSON, NULLABLE) - Alternative grid layout configuration
- `is_locked` (BOOLEAN, DEFAULT FALSE) - Prevent accidental changes
### 5. **company_work_config**
- Added columns:
- `standard_hours_per_day` (FLOAT, DEFAULT 8.0)
- `standard_hours_per_week` (FLOAT, DEFAULT 40.0)
- `overtime_enabled` (BOOLEAN, DEFAULT TRUE)
- `overtime_rate` (FLOAT, DEFAULT 1.5)
- `double_time_enabled` (BOOLEAN, DEFAULT FALSE)
- `double_time_threshold` (FLOAT, DEFAULT 12.0)
- `double_time_rate` (FLOAT, DEFAULT 2.0)
- `require_breaks` (BOOLEAN, DEFAULT TRUE)
- `break_duration_minutes` (INTEGER, DEFAULT 30)
- `break_after_hours` (FLOAT, DEFAULT 6.0)
- `weekly_overtime_threshold` (FLOAT, DEFAULT 40.0)
- `weekly_overtime_rate` (FLOAT, DEFAULT 1.5)
### 6. **company_settings**
- Added columns:
- `work_week_start` (INTEGER, DEFAULT 1)
- `work_days` (VARCHAR(20), DEFAULT '1,2,3,4,5')
- `allow_overlapping_entries` (BOOLEAN, DEFAULT FALSE)
- `require_project_for_time_entry` (BOOLEAN, DEFAULT TRUE)
- `allow_future_entries` (BOOLEAN, DEFAULT FALSE)
- `max_hours_per_entry` (FLOAT, DEFAULT 24.0)
- `enable_tasks` (BOOLEAN, DEFAULT TRUE)
- `enable_sprints` (BOOLEAN, DEFAULT FALSE)
- `enable_client_access` (BOOLEAN, DEFAULT FALSE)
- `notify_on_overtime` (BOOLEAN, DEFAULT TRUE)
- `overtime_threshold_daily` (FLOAT, DEFAULT 8.0)
- `overtime_threshold_weekly` (FLOAT, DEFAULT 40.0)
### 7. **dashboard_widget**
- Added columns:
- `config` (JSON) - Widget-specific configuration
- `is_visible` (BOOLEAN, DEFAULT TRUE)
## Enum Changes
### 1. **WorkRegion** enum
- Added value:
- `GERMANY = "Germany"` - NEW
### 2. **TaskStatus** enum
- Added value:
- `ARCHIVED = "Archived"` - NEW
### 3. **WidgetType** enum
- Expanded with many new widget types:
- Time Tracking: `CURRENT_TIMER`, `DAILY_SUMMARY`, `WEEKLY_CHART`, `BREAK_REMINDER`, `TIME_SUMMARY`
- Project Management: `ACTIVE_PROJECTS`, `PROJECT_PROGRESS`, `PROJECT_ACTIVITY`, `PROJECT_DEADLINES`, `PROJECT_STATUS`
- Task Management: `ASSIGNED_TASKS`, `TASK_PRIORITY`, `TASK_CALENDAR`, `UPCOMING_TASKS`, `TASK_LIST`
- Sprint: `SPRINT_OVERVIEW`, `SPRINT_BURNDOWN`, `SPRINT_PROGRESS`
- Team & Analytics: `TEAM_WORKLOAD`, `TEAM_PRESENCE`, `TEAM_ACTIVITY`
- Performance: `PRODUCTIVITY_STATS`, `TIME_DISTRIBUTION`, `PERSONAL_STATS`
- Actions: `QUICK_ACTIONS`, `RECENT_ACTIVITY`
## Migration Requirements
### PostgreSQL Migration Steps:
1. **Add company_invitation table** (migration 19)
2. **Add updated_at to company table** (migration 20)
3. **Add new columns to user table** for 2FA and avatar
4. **Add new columns to user_preferences table**
5. **Add new columns to user_dashboard table**
6. **Add new columns to company_work_config table**
7. **Add new columns to company_settings table**
8. **Add new columns to dashboard_widget table**
9. **Update enum types** for WorkRegion and TaskStatus
10. **Update WidgetType enum** with new values
### Data Migration Considerations:
1. **Default values**: All new columns have appropriate defaults
2. **Nullable fields**: Most new fields are nullable or have defaults
3. **Foreign keys**: New invitation table has proper FK constraints
4. **Indexes**: Performance indexes added for invitation lookups
5. **Enum migrations**: Need to handle enum type changes carefully in PostgreSQL
### Breaking Changes:
- None identified - all changes are additive or have defaults
### Rollback Strategy:
1. Drop new tables (company_invitation)
2. Drop new columns from existing tables
3. Revert enum changes (remove new values)
## Summary
The main changes involve:
1. Adding email invitation functionality with a new table
2. Enhancing user features with 2FA and avatars
3. Expanding dashboard and widget capabilities
4. Adding comprehensive work configuration options
5. Better tracking with updated_at timestamps
6. Regional compliance support with expanded WorkRegion enum

4200
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -190,7 +190,7 @@ def format_burndown_data(tasks, start_date, end_date):
# Task is remaining if:
# 1. It's not completed, OR
# 2. It was completed after this date
if task.status != TaskStatus.COMPLETED:
if task.status != TaskStatus.DONE:
remaining_count += 1
elif task.completed_date and task.completed_date > date_obj:
remaining_count += 1

View File

@@ -0,0 +1,24 @@
# Database Migration Scripts - In Order of Execution
## Phase 1: SQLite Schema Updates (Run first)
01_migrate_db.py - Update SQLite schema with all necessary columns and tables
## Phase 2: Data Migration (Run after SQLite updates)
02_migrate_sqlite_to_postgres.py - Migrate data from updated SQLite to PostgreSQL
## Phase 3: PostgreSQL Schema Migrations (Run after data migration)
03_add_dashboard_columns.py - Add missing columns to user_dashboard table
04_add_user_preferences_columns.py - Add missing columns to user_preferences table
05_fix_task_status_enum.py - Fix task status enum values in database
06_add_archived_status.py - Add ARCHIVED status to task_status enum
07_fix_company_work_config_columns.py - Fix company work config column names
08_fix_work_region_enum.py - Fix work region enum values
09_add_germany_to_workregion.py - Add GERMANY back to work_region enum
10_add_company_settings_columns.py - Add missing columns to company_settings table
## Phase 4: Code Migrations (Run after all schema migrations)
11_fix_company_work_config_usage.py - Update code references to CompanyWorkConfig fields
12_fix_task_status_usage.py - Update code references to TaskStatus enum values
13_fix_work_region_usage.py - Update code references to WorkRegion enum values
14_fix_removed_fields.py - Handle removed fields in code
15_repair_user_roles.py - Fix user roles from string to enum values

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Summary of all model migrations to be performed
"""
import os
from pathlib import Path
def print_section(title, items):
"""Print a formatted section"""
print(f"\n{'='*60}")
print(f"📌 {title}")
print('='*60)
for item in items:
print(f" {item}")
def main():
print("🔍 Model Migration Summary")
print("="*60)
print("\nThis will update your codebase to match the refactored models.")
# CompanyWorkConfig changes
print_section("CompanyWorkConfig Field Changes", [
"✓ work_hours_per_day → standard_hours_per_day",
"✓ mandatory_break_minutes → break_duration_minutes",
"✓ break_threshold_hours → break_after_hours",
"✓ region → work_region",
"✗ REMOVED: additional_break_minutes",
"✗ REMOVED: additional_break_threshold_hours",
"✗ REMOVED: region_name (use work_region.value)",
"✗ REMOVED: created_by_id",
"+ ADDED: standard_hours_per_week, overtime_enabled, overtime_rate, etc."
])
# TaskStatus changes
print_section("TaskStatus Enum Changes", [
"✓ NOT_STARTED → TODO",
"✓ COMPLETED → DONE",
"✓ ON_HOLD → IN_REVIEW",
"+ KEPT: ARCHIVED (separate from CANCELLED)"
])
# WorkRegion changes
print_section("WorkRegion Enum Changes", [
"✓ UNITED_STATES → USA",
"✓ UNITED_KINGDOM → UK",
"✓ FRANCE → EU",
"✓ EUROPEAN_UNION → EU",
"✓ CUSTOM → OTHER",
"! KEPT: GERMANY (specific labor laws)"
])
# Files to be modified
print_section("Files That Will Be Modified", [
"Python files: app.py, routes/*.py",
"Templates: admin_company.html, admin_work_policies.html, config.html",
"JavaScript: static/js/*.js (for task status)",
"Removed field references will be commented out"
])
# Safety notes
print_section("⚠️ Important Notes", [
"BACKUP your code before running migrations",
"Removed fields will be commented with # REMOVED:",
"Review all changes after migration",
"Test thoroughly, especially:",
" - Company work policy configuration",
" - Task status transitions",
" - Regional preset selection",
"Consider implementing audit logging for created_by tracking"
])
print("\n" + "="*60)
print("🎯 To run all migrations: python migrations/run_all_migrations.py")
print("🎯 To run individually: python migrations/01_fix_company_work_config_usage.py")
print("="*60)
if __name__ == "__main__":
main()

View File

@@ -10,6 +10,9 @@ import sys
import argparse
from datetime import datetime
# Add parent directory to path to import app
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Try to import from Flask app context if available
try:
from app import app, db

View File

@@ -13,6 +13,9 @@ from datetime import datetime
from psycopg2.extras import RealDictCursor
import json
# Add parent directory to path to import app
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Configure logging
logging.basicConfig(
level=logging.INFO,
@@ -183,6 +186,15 @@ class SQLiteToPostgresMigration:
data_row.append(value)
data_rows.append(tuple(data_row))
# Check if we should clear existing data first (for tables with unique constraints)
if table_name in ['company', 'team', 'user']:
postgres_cursor.execute(f'SELECT COUNT(*) FROM "{table_name}"')
existing_count = postgres_cursor.fetchone()[0]
if existing_count > 0:
logger.warning(f"Table {table_name} already has {existing_count} rows. Skipping to avoid duplicates.")
self.migration_stats[table_name] = 0
return True
# Insert data in batches
batch_size = 1000
for i in range(0, len(data_rows), batch_size):

View File

@@ -0,0 +1,361 @@
#!/usr/bin/env python3
"""
Fixed SQLite to PostgreSQL Migration Script for TimeTrack
This script properly handles empty SQLite databases and column mapping issues.
"""
import sqlite3
import psycopg2
import os
import sys
import logging
from datetime import datetime
from psycopg2.extras import RealDictCursor
import json
# Add parent directory to path to import app
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('migration.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
class SQLiteToPostgresMigration:
def __init__(self, sqlite_path, postgres_url):
self.sqlite_path = sqlite_path
self.postgres_url = postgres_url
self.sqlite_conn = None
self.postgres_conn = None
self.migration_stats = {}
# Column mapping for SQLite to PostgreSQL
self.column_mapping = {
'project': {
# Map SQLite columns to PostgreSQL columns
# Ensure company_id is properly mapped
'company_id': 'company_id',
'user_id': 'company_id' # Map user_id to company_id if needed
}
}
def connect_databases(self):
"""Connect to both SQLite and PostgreSQL databases"""
try:
# Connect to SQLite
self.sqlite_conn = sqlite3.connect(self.sqlite_path)
self.sqlite_conn.row_factory = sqlite3.Row
logger.info(f"Connected to SQLite database: {self.sqlite_path}")
# Connect to PostgreSQL
self.postgres_conn = psycopg2.connect(self.postgres_url)
self.postgres_conn.autocommit = False
logger.info("Connected to PostgreSQL database")
return True
except Exception as e:
logger.error(f"Failed to connect to databases: {e}")
return False
def close_connections(self):
"""Close database connections"""
if self.sqlite_conn:
self.sqlite_conn.close()
if self.postgres_conn:
self.postgres_conn.close()
def check_sqlite_database(self):
"""Check if SQLite database exists and has data"""
if not os.path.exists(self.sqlite_path):
logger.error(f"SQLite database not found: {self.sqlite_path}")
return False
try:
cursor = self.sqlite_conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = cursor.fetchall()
if not tables:
logger.info("SQLite database is empty, nothing to migrate")
return False
logger.info(f"Found {len(tables)} tables in SQLite database")
for table in tables:
logger.info(f" - {table[0]}")
return True
except Exception as e:
logger.error(f"Error checking SQLite database: {e}")
return False
def clear_postgres_data(self):
"""Clear existing data from PostgreSQL tables that will be migrated"""
try:
with self.postgres_conn.cursor() as cursor:
# Tables to clear in reverse order of dependencies
tables_to_clear = [
'time_entry',
'sub_task',
'task',
'project',
'user',
'team',
'company',
'work_config',
'system_settings'
]
for table in tables_to_clear:
try:
cursor.execute(f'DELETE FROM "{table}"')
logger.info(f"Cleared table: {table}")
except Exception as e:
logger.warning(f"Could not clear table {table}: {e}")
self.postgres_conn.rollback()
self.postgres_conn.commit()
return True
except Exception as e:
logger.error(f"Failed to clear PostgreSQL data: {e}")
self.postgres_conn.rollback()
return False
def migrate_table_data(self, table_name):
"""Migrate data from SQLite table to PostgreSQL"""
try:
sqlite_cursor = self.sqlite_conn.cursor()
postgres_cursor = self.postgres_conn.cursor()
# Check if table exists in SQLite
sqlite_cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,))
if not sqlite_cursor.fetchone():
logger.info(f"Table {table_name} does not exist in SQLite, skipping...")
self.migration_stats[table_name] = 0
return True
# Get data from SQLite
sqlite_cursor.execute(f"SELECT * FROM {table_name}")
rows = sqlite_cursor.fetchall()
if not rows:
logger.info(f"No data found in table: {table_name}")
self.migration_stats[table_name] = 0
return True
# Get column names from SQLite
column_names = [description[0] for description in sqlite_cursor.description]
logger.info(f"SQLite columns for {table_name}: {column_names}")
# Get PostgreSQL column names
postgres_cursor.execute(f"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = %s
ORDER BY ordinal_position
""", (table_name,))
pg_columns = [row[0] for row in postgres_cursor.fetchall()]
logger.info(f"PostgreSQL columns for {table_name}: {pg_columns}")
# For project table, ensure company_id is properly handled
if table_name == 'project':
# Check if company_id exists in the data
for i, row in enumerate(rows):
row_dict = dict(zip(column_names, row))
if 'company_id' not in row_dict or row_dict['company_id'] is None:
# If user_id exists, use it as company_id
if 'user_id' in row_dict and row_dict['user_id'] is not None:
logger.info(f"Mapping user_id {row_dict['user_id']} to company_id for project {row_dict.get('id')}")
# Update the row data
row_list = list(row)
if 'company_id' in column_names:
company_id_idx = column_names.index('company_id')
user_id_idx = column_names.index('user_id')
row_list[company_id_idx] = row_list[user_id_idx]
else:
# Add company_id column
column_names.append('company_id')
user_id_idx = column_names.index('user_id')
row_list.append(row[user_id_idx])
rows[i] = tuple(row_list)
# Filter columns to only those that exist in PostgreSQL
valid_columns = [col for col in column_names if col in pg_columns]
column_indices = [column_names.index(col) for col in valid_columns]
# Prepare insert statement
placeholders = ', '.join(['%s'] * len(valid_columns))
columns = ', '.join([f'"{col}"' for col in valid_columns])
insert_sql = f'INSERT INTO "{table_name}" ({columns}) VALUES ({placeholders})'
# Convert rows to list of tuples with only valid columns
data_rows = []
for row in rows:
data_row = []
for i in column_indices:
value = row[i]
col_name = valid_columns[column_indices.index(i)]
# Handle special data type conversions
if value is None:
data_row.append(None)
elif isinstance(value, str) and value.startswith('{"') and value.endswith('}'):
# Handle JSON strings
data_row.append(value)
elif (col_name.startswith('is_') or col_name.endswith('_enabled') or col_name in ['is_paused']) and isinstance(value, int):
# Convert integer boolean to actual boolean for PostgreSQL
data_row.append(bool(value))
elif isinstance(value, str) and value == '':
# Convert empty strings to None for PostgreSQL
data_row.append(None)
else:
data_row.append(value)
data_rows.append(tuple(data_row))
# Insert data one by one to better handle errors
successful_inserts = 0
for i, row in enumerate(data_rows):
try:
postgres_cursor.execute(insert_sql, row)
self.postgres_conn.commit()
successful_inserts += 1
except Exception as row_error:
logger.error(f"Error inserting row {i} in table {table_name}: {row_error}")
logger.error(f"Problematic row data: {row}")
logger.error(f"Columns: {valid_columns}")
self.postgres_conn.rollback()
logger.info(f"Migrated {successful_inserts}/{len(rows)} rows from table: {table_name}")
self.migration_stats[table_name] = successful_inserts
return True
except Exception as e:
logger.error(f"Failed to migrate table {table_name}: {e}")
self.postgres_conn.rollback()
return False
def update_sequences(self):
"""Update PostgreSQL sequences after data migration"""
try:
with self.postgres_conn.cursor() as cursor:
# Get all sequences
cursor.execute("""
SELECT
pg_get_serial_sequence(table_name, column_name) as sequence_name,
column_name,
table_name
FROM information_schema.columns
WHERE column_default LIKE 'nextval%'
AND table_schema = 'public'
""")
sequences = cursor.fetchall()
for seq_name, col_name, table_name in sequences:
if seq_name is None:
continue
# Get the maximum value for each sequence
cursor.execute(f'SELECT MAX("{col_name}") FROM "{table_name}"')
max_val = cursor.fetchone()[0]
if max_val is not None:
# Update sequence to start from max_val + 1
cursor.execute(f'ALTER SEQUENCE {seq_name} RESTART WITH {max_val + 1}')
logger.info(f"Updated sequence {seq_name} to start from {max_val + 1}")
self.postgres_conn.commit()
logger.info("Updated PostgreSQL sequences")
return True
except Exception as e:
logger.error(f"Failed to update sequences: {e}")
self.postgres_conn.rollback()
return False
def run_migration(self, clear_existing=False):
"""Run the complete migration process"""
logger.info("Starting SQLite to PostgreSQL migration...")
# Connect to databases
if not self.connect_databases():
return False
try:
# Check SQLite database
if not self.check_sqlite_database():
logger.info("No data to migrate from SQLite")
return True
# Clear existing PostgreSQL data if requested
if clear_existing:
if not self.clear_postgres_data():
logger.warning("Failed to clear some PostgreSQL data, continuing anyway...")
# Define table migration order (respecting foreign key constraints)
migration_order = [
'company',
'team',
'project_category',
'user',
'project',
'task',
'sub_task',
'time_entry',
'work_config',
'company_work_config',
'user_preferences',
'system_settings'
]
# Migrate data
for table_name in migration_order:
if not self.migrate_table_data(table_name):
logger.error(f"Migration failed at table: {table_name}")
# Update sequences after all data is migrated
if not self.update_sequences():
logger.error("Failed to update sequences")
logger.info("Migration completed!")
logger.info(f"Migration statistics: {self.migration_stats}")
return True
except Exception as e:
logger.error(f"Migration failed: {e}")
return False
finally:
self.close_connections()
def main():
"""Main migration function"""
import argparse
parser = argparse.ArgumentParser(description='Migrate SQLite to PostgreSQL')
parser.add_argument('--clear-existing', action='store_true',
help='Clear existing PostgreSQL data before migration')
parser.add_argument('--sqlite-path', default=os.environ.get('SQLITE_PATH', '/data/timetrack.db'),
help='Path to SQLite database')
args = parser.parse_args()
# Get database paths from environment variables
sqlite_path = args.sqlite_path
postgres_url = os.environ.get('DATABASE_URL')
if not postgres_url:
logger.error("DATABASE_URL environment variable not set")
return 1
# Check if SQLite database exists
if not os.path.exists(sqlite_path):
logger.info(f"SQLite database not found at {sqlite_path}, skipping migration")
return 0
# Run migration
migration = SQLiteToPostgresMigration(sqlite_path, postgres_url)
success = migration.run_migration(clear_existing=args.clear_existing)
return 0 if success else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""
Add missing columns to user_dashboard table
"""
import os
import psycopg2
from psycopg2 import sql
from urllib.parse import urlparse
# Get database URL from environment
DATABASE_URL = os.environ.get('DATABASE_URL', 'postgresql://timetrack:timetrack123@localhost:5432/timetrack')
def add_missing_columns():
"""Add missing columns to user_dashboard table"""
# Parse database URL
parsed = urlparse(DATABASE_URL)
# Connect to database
conn = psycopg2.connect(
host=parsed.hostname,
port=parsed.port or 5432,
user=parsed.username,
password=parsed.password,
database=parsed.path[1:] # Remove leading slash
)
try:
with conn.cursor() as cur:
# Check if columns exist
cur.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'user_dashboard'
AND column_name IN ('layout', 'is_locked', 'created_at', 'updated_at',
'name', 'is_default', 'layout_config', 'grid_columns',
'theme', 'auto_refresh')
""")
existing_columns = [row[0] for row in cur.fetchall()]
# Add missing columns
if 'name' not in existing_columns:
print("Adding 'name' column to user_dashboard table...")
cur.execute("ALTER TABLE user_dashboard ADD COLUMN name VARCHAR(100) DEFAULT 'My Dashboard'")
print("Added 'name' column")
if 'is_default' not in existing_columns:
print("Adding 'is_default' column to user_dashboard table...")
cur.execute("ALTER TABLE user_dashboard ADD COLUMN is_default BOOLEAN DEFAULT TRUE")
print("Added 'is_default' column")
if 'layout_config' not in existing_columns:
print("Adding 'layout_config' column to user_dashboard table...")
cur.execute("ALTER TABLE user_dashboard ADD COLUMN layout_config TEXT")
print("Added 'layout_config' column")
if 'grid_columns' not in existing_columns:
print("Adding 'grid_columns' column to user_dashboard table...")
cur.execute("ALTER TABLE user_dashboard ADD COLUMN grid_columns INTEGER DEFAULT 6")
print("Added 'grid_columns' column")
if 'theme' not in existing_columns:
print("Adding 'theme' column to user_dashboard table...")
cur.execute("ALTER TABLE user_dashboard ADD COLUMN theme VARCHAR(20) DEFAULT 'light'")
print("Added 'theme' column")
if 'auto_refresh' not in existing_columns:
print("Adding 'auto_refresh' column to user_dashboard table...")
cur.execute("ALTER TABLE user_dashboard ADD COLUMN auto_refresh INTEGER DEFAULT 300")
print("Added 'auto_refresh' column")
if 'layout' not in existing_columns:
print("Adding 'layout' column to user_dashboard table...")
cur.execute("ALTER TABLE user_dashboard ADD COLUMN layout JSON")
print("Added 'layout' column")
if 'is_locked' not in existing_columns:
print("Adding 'is_locked' column to user_dashboard table...")
cur.execute("ALTER TABLE user_dashboard ADD COLUMN is_locked BOOLEAN DEFAULT FALSE")
print("Added 'is_locked' column")
if 'created_at' not in existing_columns:
print("Adding 'created_at' column to user_dashboard table...")
cur.execute("ALTER TABLE user_dashboard ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
print("Added 'created_at' column")
if 'updated_at' not in existing_columns:
print("Adding 'updated_at' column to user_dashboard table...")
cur.execute("ALTER TABLE user_dashboard ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
print("Added 'updated_at' column")
# Commit changes
conn.commit()
print("Dashboard columns migration completed successfully!")
except Exception as e:
print(f"Error during migration: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
add_missing_columns()

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""
Add missing columns to user_preferences table
"""
import os
import psycopg2
from psycopg2 import sql
from urllib.parse import urlparse
# Get database URL from environment
DATABASE_URL = os.environ.get('DATABASE_URL', 'postgresql://timetrack:timetrack123@localhost:5432/timetrack')
def add_missing_columns():
"""Add missing columns to user_preferences table"""
# Parse database URL
parsed = urlparse(DATABASE_URL)
# Connect to database
conn = psycopg2.connect(
host=parsed.hostname,
port=parsed.port or 5432,
user=parsed.username,
password=parsed.password,
database=parsed.path[1:] # Remove leading slash
)
try:
with conn.cursor() as cur:
# Check if table exists
cur.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'user_preferences'
)
""")
table_exists = cur.fetchone()[0]
if not table_exists:
print("user_preferences table does not exist. Creating it...")
cur.execute("""
CREATE TABLE user_preferences (
id SERIAL PRIMARY KEY,
user_id INTEGER UNIQUE NOT NULL REFERENCES "user"(id),
theme VARCHAR(20) DEFAULT 'light',
language VARCHAR(10) DEFAULT 'en',
timezone VARCHAR(50) DEFAULT 'UTC',
date_format VARCHAR(20) DEFAULT 'YYYY-MM-DD',
time_format VARCHAR(10) DEFAULT '24h',
email_notifications BOOLEAN DEFAULT TRUE,
email_daily_summary BOOLEAN DEFAULT FALSE,
email_weekly_summary BOOLEAN DEFAULT TRUE,
default_project_id INTEGER REFERENCES project(id),
timer_reminder_enabled BOOLEAN DEFAULT TRUE,
timer_reminder_interval INTEGER DEFAULT 60,
dashboard_layout JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
print("Created user_preferences table")
else:
# Check which columns exist
cur.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'user_preferences'
AND column_name IN ('theme', 'language', 'timezone', 'date_format',
'time_format', 'email_notifications', 'email_daily_summary',
'email_weekly_summary', 'default_project_id',
'timer_reminder_enabled', 'timer_reminder_interval',
'dashboard_layout', 'created_at', 'updated_at')
""")
existing_columns = [row[0] for row in cur.fetchall()]
# Add missing columns
if 'theme' not in existing_columns:
print("Adding 'theme' column to user_preferences table...")
cur.execute("ALTER TABLE user_preferences ADD COLUMN theme VARCHAR(20) DEFAULT 'light'")
print("Added 'theme' column")
if 'language' not in existing_columns:
print("Adding 'language' column to user_preferences table...")
cur.execute("ALTER TABLE user_preferences ADD COLUMN language VARCHAR(10) DEFAULT 'en'")
print("Added 'language' column")
if 'timezone' not in existing_columns:
print("Adding 'timezone' column to user_preferences table...")
cur.execute("ALTER TABLE user_preferences ADD COLUMN timezone VARCHAR(50) DEFAULT 'UTC'")
print("Added 'timezone' column")
if 'date_format' not in existing_columns:
print("Adding 'date_format' column to user_preferences table...")
cur.execute("ALTER TABLE user_preferences ADD COLUMN date_format VARCHAR(20) DEFAULT 'YYYY-MM-DD'")
print("Added 'date_format' column")
if 'time_format' not in existing_columns:
print("Adding 'time_format' column to user_preferences table...")
cur.execute("ALTER TABLE user_preferences ADD COLUMN time_format VARCHAR(10) DEFAULT '24h'")
print("Added 'time_format' column")
if 'email_notifications' not in existing_columns:
print("Adding 'email_notifications' column to user_preferences table...")
cur.execute("ALTER TABLE user_preferences ADD COLUMN email_notifications BOOLEAN DEFAULT TRUE")
print("Added 'email_notifications' column")
if 'email_daily_summary' not in existing_columns:
print("Adding 'email_daily_summary' column to user_preferences table...")
cur.execute("ALTER TABLE user_preferences ADD COLUMN email_daily_summary BOOLEAN DEFAULT FALSE")
print("Added 'email_daily_summary' column")
if 'email_weekly_summary' not in existing_columns:
print("Adding 'email_weekly_summary' column to user_preferences table...")
cur.execute("ALTER TABLE user_preferences ADD COLUMN email_weekly_summary BOOLEAN DEFAULT TRUE")
print("Added 'email_weekly_summary' column")
if 'default_project_id' not in existing_columns:
print("Adding 'default_project_id' column to user_preferences table...")
cur.execute("ALTER TABLE user_preferences ADD COLUMN default_project_id INTEGER REFERENCES project(id)")
print("Added 'default_project_id' column")
if 'timer_reminder_enabled' not in existing_columns:
print("Adding 'timer_reminder_enabled' column to user_preferences table...")
cur.execute("ALTER TABLE user_preferences ADD COLUMN timer_reminder_enabled BOOLEAN DEFAULT TRUE")
print("Added 'timer_reminder_enabled' column")
if 'timer_reminder_interval' not in existing_columns:
print("Adding 'timer_reminder_interval' column to user_preferences table...")
cur.execute("ALTER TABLE user_preferences ADD COLUMN timer_reminder_interval INTEGER DEFAULT 60")
print("Added 'timer_reminder_interval' column")
if 'dashboard_layout' not in existing_columns:
print("Adding 'dashboard_layout' column to user_preferences table...")
cur.execute("ALTER TABLE user_preferences ADD COLUMN dashboard_layout JSON")
print("Added 'dashboard_layout' column")
if 'created_at' not in existing_columns:
print("Adding 'created_at' column to user_preferences table...")
cur.execute("ALTER TABLE user_preferences ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
print("Added 'created_at' column")
if 'updated_at' not in existing_columns:
print("Adding 'updated_at' column to user_preferences table...")
cur.execute("ALTER TABLE user_preferences ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
print("Added 'updated_at' column")
# Commit changes
conn.commit()
print("User preferences migration completed successfully!")
except Exception as e:
print(f"Error during migration: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
add_missing_columns()

View File

@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""
Fix task status enum in the database to match Python enum
"""
import os
import psycopg2
from psycopg2 import sql
from urllib.parse import urlparse
# Get database URL from environment
DATABASE_URL = os.environ.get('DATABASE_URL', 'postgresql://timetrack:timetrack123@localhost:5432/timetrack')
def fix_task_status_enum():
"""Update task status enum in database"""
# Parse database URL
parsed = urlparse(DATABASE_URL)
# Connect to database
conn = psycopg2.connect(
host=parsed.hostname,
port=parsed.port or 5432,
user=parsed.username,
password=parsed.password,
database=parsed.path[1:] # Remove leading slash
)
try:
with conn.cursor() as cur:
print("Starting task status enum migration...")
# First check if the enum already has the correct values
cur.execute("""
SELECT enumlabel
FROM pg_enum
WHERE enumtypid = (SELECT oid FROM pg_type WHERE typname = 'taskstatus')
ORDER BY enumsortorder
""")
current_values = [row[0] for row in cur.fetchall()]
print(f"Current enum values: {current_values}")
# Check if migration is needed
expected_values = ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE', 'CANCELLED']
if all(val in current_values for val in expected_values):
print("Task status enum already has correct values. Skipping migration.")
return
# Check if task table exists and has a status column
cur.execute("""
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'task' AND column_name = 'status'
""")
if not cur.fetchone():
print("No task table or status column found. Skipping migration.")
return
# Check if temporary column already exists
cur.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'task' AND column_name = 'status_temp'
""")
temp_exists = cur.fetchone() is not None
if not temp_exists:
# First, we need to create a temporary column to hold the data
print("1. Creating temporary column...")
cur.execute("ALTER TABLE task ADD COLUMN status_temp VARCHAR(50)")
cur.execute("ALTER TABLE sub_task ADD COLUMN status_temp VARCHAR(50)")
else:
print("1. Temporary column already exists...")
# Copy current status values to temp column with mapping
print("2. Copying and mapping status values...")
# First check what values actually exist in the database
cur.execute("SELECT DISTINCT status::text FROM task WHERE status IS NOT NULL")
existing_statuses = [row[0] for row in cur.fetchall()]
print(f" Existing status values in task table: {existing_statuses}")
# If no statuses exist, skip the mapping
if not existing_statuses:
print(" No existing status values to migrate")
else:
# Build dynamic mapping based on what exists
mapping_sql = "UPDATE task SET status_temp = CASE "
has_cases = False
if 'NOT_STARTED' in existing_statuses:
mapping_sql += "WHEN status::text = 'NOT_STARTED' THEN 'TODO' "
has_cases = True
if 'TODO' in existing_statuses:
mapping_sql += "WHEN status::text = 'TODO' THEN 'TODO' "
has_cases = True
if 'IN_PROGRESS' in existing_statuses:
mapping_sql += "WHEN status::text = 'IN_PROGRESS' THEN 'IN_PROGRESS' "
has_cases = True
if 'ON_HOLD' in existing_statuses:
mapping_sql += "WHEN status::text = 'ON_HOLD' THEN 'IN_REVIEW' "
has_cases = True
if 'IN_REVIEW' in existing_statuses:
mapping_sql += "WHEN status::text = 'IN_REVIEW' THEN 'IN_REVIEW' "
has_cases = True
if 'COMPLETED' in existing_statuses:
mapping_sql += "WHEN status::text = 'COMPLETED' THEN 'DONE' "
has_cases = True
if 'DONE' in existing_statuses:
mapping_sql += "WHEN status::text = 'DONE' THEN 'DONE' "
has_cases = True
if 'CANCELLED' in existing_statuses:
mapping_sql += "WHEN status::text = 'CANCELLED' THEN 'CANCELLED' "
has_cases = True
if 'ARCHIVED' in existing_statuses:
mapping_sql += "WHEN status::text = 'ARCHIVED' THEN 'CANCELLED' "
has_cases = True
if has_cases:
mapping_sql += "ELSE status::text END WHERE status IS NOT NULL"
cur.execute(mapping_sql)
print(f" Updated {cur.rowcount} tasks")
# Check sub_task table
cur.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'sub_task' AND column_name = 'status'
""")
if cur.fetchone():
# Get existing subtask statuses
cur.execute("SELECT DISTINCT status::text FROM sub_task WHERE status IS NOT NULL")
existing_subtask_statuses = [row[0] for row in cur.fetchall()]
print(f" Existing status values in sub_task table: {existing_subtask_statuses}")
# If no statuses exist, skip the mapping
if not existing_subtask_statuses:
print(" No existing subtask status values to migrate")
else:
# Build dynamic mapping for subtasks
mapping_sql = "UPDATE sub_task SET status_temp = CASE "
has_cases = False
if 'NOT_STARTED' in existing_subtask_statuses:
mapping_sql += "WHEN status::text = 'NOT_STARTED' THEN 'TODO' "
has_cases = True
if 'TODO' in existing_subtask_statuses:
mapping_sql += "WHEN status::text = 'TODO' THEN 'TODO' "
has_cases = True
if 'IN_PROGRESS' in existing_subtask_statuses:
mapping_sql += "WHEN status::text = 'IN_PROGRESS' THEN 'IN_PROGRESS' "
has_cases = True
if 'ON_HOLD' in existing_subtask_statuses:
mapping_sql += "WHEN status::text = 'ON_HOLD' THEN 'IN_REVIEW' "
has_cases = True
if 'IN_REVIEW' in existing_subtask_statuses:
mapping_sql += "WHEN status::text = 'IN_REVIEW' THEN 'IN_REVIEW' "
has_cases = True
if 'COMPLETED' in existing_subtask_statuses:
mapping_sql += "WHEN status::text = 'COMPLETED' THEN 'DONE' "
has_cases = True
if 'DONE' in existing_subtask_statuses:
mapping_sql += "WHEN status::text = 'DONE' THEN 'DONE' "
has_cases = True
if 'CANCELLED' in existing_subtask_statuses:
mapping_sql += "WHEN status::text = 'CANCELLED' THEN 'CANCELLED' "
has_cases = True
if 'ARCHIVED' in existing_subtask_statuses:
mapping_sql += "WHEN status::text = 'ARCHIVED' THEN 'CANCELLED' "
has_cases = True
if has_cases:
mapping_sql += "ELSE status::text END WHERE status IS NOT NULL"
cur.execute(mapping_sql)
print(f" Updated {cur.rowcount} subtasks")
# Drop the old status columns
print("3. Dropping old status columns...")
cur.execute("ALTER TABLE task DROP COLUMN status")
cur.execute("ALTER TABLE sub_task DROP COLUMN status")
# Drop the old enum type
print("4. Dropping old enum type...")
cur.execute("DROP TYPE IF EXISTS taskstatus")
# Create new enum type with correct values
print("5. Creating new enum type...")
cur.execute("""
CREATE TYPE taskstatus AS ENUM (
'TODO',
'IN_PROGRESS',
'IN_REVIEW',
'DONE',
'CANCELLED'
)
""")
# Add new status columns with correct enum type
print("6. Adding new status columns...")
cur.execute("ALTER TABLE task ADD COLUMN status taskstatus")
cur.execute("ALTER TABLE sub_task ADD COLUMN status taskstatus")
# Copy data from temp columns to new status columns
print("7. Copying data to new columns...")
cur.execute("UPDATE task SET status = status_temp::taskstatus")
cur.execute("UPDATE sub_task SET status = status_temp::taskstatus")
# Drop temporary columns
print("8. Dropping temporary columns...")
cur.execute("ALTER TABLE task DROP COLUMN status_temp")
cur.execute("ALTER TABLE sub_task DROP COLUMN status_temp")
# Add NOT NULL constraint
print("9. Adding NOT NULL constraints...")
cur.execute("ALTER TABLE task ALTER COLUMN status SET NOT NULL")
cur.execute("ALTER TABLE sub_task ALTER COLUMN status SET NOT NULL")
# Set default value
print("10. Setting default values...")
cur.execute("ALTER TABLE task ALTER COLUMN status SET DEFAULT 'TODO'")
cur.execute("ALTER TABLE sub_task ALTER COLUMN status SET DEFAULT 'TODO'")
# Commit changes
conn.commit()
print("\nTask status enum migration completed successfully!")
# Verify the new enum values
print("\nVerifying new enum values:")
cur.execute("""
SELECT enumlabel
FROM pg_enum
WHERE enumtypid = (
SELECT oid FROM pg_type WHERE typname = 'taskstatus'
)
ORDER BY enumsortorder
""")
for row in cur.fetchall():
print(f" - {row[0]}")
except Exception as e:
print(f"Error during migration: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
fix_task_status_enum()

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""
Add ARCHIVED status back to task status enum
"""
import os
import psycopg2
from psycopg2 import sql
from urllib.parse import urlparse
# Get database URL from environment
DATABASE_URL = os.environ.get('DATABASE_URL', 'postgresql://timetrack:timetrack123@localhost:5432/timetrack')
def add_archived_status():
"""Add ARCHIVED status to task status enum"""
# Parse database URL
parsed = urlparse(DATABASE_URL)
# Connect to database
conn = psycopg2.connect(
host=parsed.hostname,
port=parsed.port or 5432,
user=parsed.username,
password=parsed.password,
database=parsed.path[1:] # Remove leading slash
)
try:
with conn.cursor() as cur:
print("Adding ARCHIVED status to taskstatus enum...")
# Check if ARCHIVED already exists
cur.execute("""
SELECT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumtypid = (SELECT oid FROM pg_type WHERE typname = 'taskstatus')
AND enumlabel = 'ARCHIVED'
)
""")
if cur.fetchone()[0]:
print("ARCHIVED status already exists in enum")
return
# Add ARCHIVED to the enum
cur.execute("""
ALTER TYPE taskstatus ADD VALUE IF NOT EXISTS 'ARCHIVED' AFTER 'CANCELLED'
""")
print("Successfully added ARCHIVED status to enum")
# Verify the enum values
print("\nCurrent taskstatus enum values:")
cur.execute("""
SELECT enumlabel
FROM pg_enum
WHERE enumtypid = (
SELECT oid FROM pg_type WHERE typname = 'taskstatus'
)
ORDER BY enumsortorder
""")
for row in cur.fetchall():
print(f" - {row[0]}")
# Commit changes
conn.commit()
print("\nMigration completed successfully!")
except Exception as e:
print(f"Error during migration: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
add_archived_status()

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
Fix company_work_config table columns to match model definition
"""
import os
import psycopg2
from psycopg2 import sql
from urllib.parse import urlparse
# Get database URL from environment
DATABASE_URL = os.environ.get('DATABASE_URL', 'postgresql://timetrack:timetrack123@localhost:5432/timetrack')
def fix_company_work_config_columns():
"""Rename and add columns to match the new model definition"""
# Parse database URL
parsed = urlparse(DATABASE_URL)
# Connect to database
conn = psycopg2.connect(
host=parsed.hostname,
port=parsed.port or 5432,
user=parsed.username,
password=parsed.password,
database=parsed.path[1:] # Remove leading slash
)
try:
with conn.cursor() as cur:
# Check which columns exist
cur.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'company_work_config'
""")
existing_columns = [row[0] for row in cur.fetchall()]
print(f"Existing columns: {existing_columns}")
# Rename columns if they exist with old names
if 'work_hours_per_day' in existing_columns and 'standard_hours_per_day' not in existing_columns:
print("Renaming work_hours_per_day to standard_hours_per_day...")
cur.execute("ALTER TABLE company_work_config RENAME COLUMN work_hours_per_day TO standard_hours_per_day")
# Add missing columns
if 'standard_hours_per_day' not in existing_columns and 'work_hours_per_day' not in existing_columns:
print("Adding standard_hours_per_day column...")
cur.execute("ALTER TABLE company_work_config ADD COLUMN standard_hours_per_day FLOAT DEFAULT 8.0")
if 'standard_hours_per_week' not in existing_columns:
print("Adding standard_hours_per_week column...")
cur.execute("ALTER TABLE company_work_config ADD COLUMN standard_hours_per_week FLOAT DEFAULT 40.0")
# Rename region to work_region if needed
if 'region' in existing_columns and 'work_region' not in existing_columns:
print("Renaming region to work_region...")
cur.execute("ALTER TABLE company_work_config RENAME COLUMN region TO work_region")
elif 'work_region' not in existing_columns:
print("Adding work_region column...")
cur.execute("ALTER TABLE company_work_config ADD COLUMN work_region VARCHAR(50) DEFAULT 'OTHER'")
# Add new columns that don't exist
if 'overtime_enabled' not in existing_columns:
print("Adding overtime_enabled column...")
cur.execute("ALTER TABLE company_work_config ADD COLUMN overtime_enabled BOOLEAN DEFAULT TRUE")
if 'overtime_rate' not in existing_columns:
print("Adding overtime_rate column...")
cur.execute("ALTER TABLE company_work_config ADD COLUMN overtime_rate FLOAT DEFAULT 1.5")
if 'double_time_enabled' not in existing_columns:
print("Adding double_time_enabled column...")
cur.execute("ALTER TABLE company_work_config ADD COLUMN double_time_enabled BOOLEAN DEFAULT FALSE")
if 'double_time_threshold' not in existing_columns:
print("Adding double_time_threshold column...")
cur.execute("ALTER TABLE company_work_config ADD COLUMN double_time_threshold FLOAT DEFAULT 12.0")
if 'double_time_rate' not in existing_columns:
print("Adding double_time_rate column...")
cur.execute("ALTER TABLE company_work_config ADD COLUMN double_time_rate FLOAT DEFAULT 2.0")
if 'require_breaks' not in existing_columns:
print("Adding require_breaks column...")
cur.execute("ALTER TABLE company_work_config ADD COLUMN require_breaks BOOLEAN DEFAULT TRUE")
if 'break_duration_minutes' not in existing_columns:
# Rename mandatory_break_minutes if it exists
if 'mandatory_break_minutes' in existing_columns:
print("Renaming mandatory_break_minutes to break_duration_minutes...")
cur.execute("ALTER TABLE company_work_config RENAME COLUMN mandatory_break_minutes TO break_duration_minutes")
else:
print("Adding break_duration_minutes column...")
cur.execute("ALTER TABLE company_work_config ADD COLUMN break_duration_minutes INTEGER DEFAULT 30")
if 'break_after_hours' not in existing_columns:
# Rename break_threshold_hours if it exists
if 'break_threshold_hours' in existing_columns:
print("Renaming break_threshold_hours to break_after_hours...")
cur.execute("ALTER TABLE company_work_config RENAME COLUMN break_threshold_hours TO break_after_hours")
else:
print("Adding break_after_hours column...")
cur.execute("ALTER TABLE company_work_config ADD COLUMN break_after_hours FLOAT DEFAULT 6.0")
if 'weekly_overtime_threshold' not in existing_columns:
print("Adding weekly_overtime_threshold column...")
cur.execute("ALTER TABLE company_work_config ADD COLUMN weekly_overtime_threshold FLOAT DEFAULT 40.0")
if 'weekly_overtime_rate' not in existing_columns:
print("Adding weekly_overtime_rate column...")
cur.execute("ALTER TABLE company_work_config ADD COLUMN weekly_overtime_rate FLOAT DEFAULT 1.5")
# Drop columns that are no longer needed
if 'region_name' in existing_columns:
print("Dropping region_name column...")
cur.execute("ALTER TABLE company_work_config DROP COLUMN region_name")
if 'additional_break_minutes' in existing_columns:
print("Dropping additional_break_minutes column...")
cur.execute("ALTER TABLE company_work_config DROP COLUMN additional_break_minutes")
if 'additional_break_threshold_hours' in existing_columns:
print("Dropping additional_break_threshold_hours column...")
cur.execute("ALTER TABLE company_work_config DROP COLUMN additional_break_threshold_hours")
if 'created_by_id' in existing_columns:
print("Dropping created_by_id column...")
cur.execute("ALTER TABLE company_work_config DROP COLUMN created_by_id")
# Commit changes
conn.commit()
print("\nCompany work config migration completed successfully!")
except Exception as e:
print(f"Error during migration: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
fix_company_work_config_columns()

View File

@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""
Fix work region enum values in the database
"""
import os
import psycopg2
from psycopg2 import sql
from urllib.parse import urlparse
# Get database URL from environment
DATABASE_URL = os.environ.get('DATABASE_URL', 'postgresql://timetrack:timetrack123@localhost:5432/timetrack')
def fix_work_region_enum():
"""Update work region enum values in database"""
# Parse database URL
parsed = urlparse(DATABASE_URL)
# Connect to database
conn = psycopg2.connect(
host=parsed.hostname,
port=parsed.port or 5432,
user=parsed.username,
password=parsed.password,
database=parsed.path[1:] # Remove leading slash
)
try:
with conn.cursor() as cur:
print("Starting work region enum migration...")
# First check if work_region column is using enum type
cur.execute("""
SELECT data_type
FROM information_schema.columns
WHERE table_name = 'company_work_config'
AND column_name = 'work_region'
""")
data_type = cur.fetchone()
if data_type and data_type[0] == 'USER-DEFINED':
# It's an enum, we need to update it
print("work_region is an enum type, migrating...")
# Create temporary column
print("1. Creating temporary column...")
cur.execute("ALTER TABLE company_work_config ADD COLUMN work_region_temp VARCHAR(50)")
# Copy and map values
print("2. Copying and mapping values...")
cur.execute("""
UPDATE company_work_config SET work_region_temp = CASE
WHEN work_region::text = 'GERMANY' THEN 'EU'
WHEN work_region::text = 'DE' THEN 'EU'
WHEN work_region::text = 'UNITED_STATES' THEN 'USA'
WHEN work_region::text = 'US' THEN 'USA'
WHEN work_region::text = 'UNITED_KINGDOM' THEN 'UK'
WHEN work_region::text = 'GB' THEN 'UK'
WHEN work_region::text = 'FRANCE' THEN 'EU'
WHEN work_region::text = 'FR' THEN 'EU'
WHEN work_region::text = 'EUROPEAN_UNION' THEN 'EU'
WHEN work_region::text = 'CUSTOM' THEN 'OTHER'
ELSE COALESCE(work_region::text, 'OTHER')
END
""")
print(f" Updated {cur.rowcount} rows")
# Drop old column
print("3. Dropping old work_region column...")
cur.execute("ALTER TABLE company_work_config DROP COLUMN work_region")
# Check if enum type exists and drop it
cur.execute("""
SELECT EXISTS (
SELECT 1 FROM pg_type WHERE typname = 'workregion'
)
""")
if cur.fetchone()[0]:
print("4. Dropping old workregion enum type...")
cur.execute("DROP TYPE IF EXISTS workregion CASCADE")
# Create new enum type
print("5. Creating new workregion enum type...")
cur.execute("""
CREATE TYPE workregion AS ENUM (
'USA',
'CANADA',
'UK',
'EU',
'AUSTRALIA',
'OTHER'
)
""")
# Add new column with enum type
print("6. Adding new work_region column...")
cur.execute("ALTER TABLE company_work_config ADD COLUMN work_region workregion DEFAULT 'OTHER'")
# Copy data back
print("7. Copying data to new column...")
cur.execute("UPDATE company_work_config SET work_region = work_region_temp::workregion")
# Drop temporary column
print("8. Dropping temporary column...")
cur.execute("ALTER TABLE company_work_config DROP COLUMN work_region_temp")
else:
# It's already a varchar, just update the values
print("work_region is already a varchar, updating values...")
cur.execute("""
UPDATE company_work_config SET work_region = CASE
WHEN work_region = 'GERMANY' THEN 'EU'
WHEN work_region = 'DE' THEN 'EU'
WHEN work_region = 'UNITED_STATES' THEN 'USA'
WHEN work_region = 'US' THEN 'USA'
WHEN work_region = 'UNITED_KINGDOM' THEN 'UK'
WHEN work_region = 'GB' THEN 'UK'
WHEN work_region = 'FRANCE' THEN 'EU'
WHEN work_region = 'FR' THEN 'EU'
WHEN work_region = 'EUROPEAN_UNION' THEN 'EU'
WHEN work_region = 'CUSTOM' THEN 'OTHER'
ELSE COALESCE(work_region, 'OTHER')
END
""")
print(f"Updated {cur.rowcount} rows")
# Commit changes
conn.commit()
print("\nWork region enum migration completed successfully!")
# Verify the results
print("\nCurrent work_region values in database:")
cur.execute("SELECT DISTINCT work_region FROM company_work_config ORDER BY work_region")
for row in cur.fetchall():
print(f" - {row[0]}")
except Exception as e:
print(f"Error during migration: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
fix_work_region_enum()

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""
Add GERMANY back to work region enum
"""
import os
import psycopg2
from psycopg2 import sql
from urllib.parse import urlparse
# Get database URL from environment
DATABASE_URL = os.environ.get('DATABASE_URL', 'postgresql://timetrack:timetrack123@localhost:5432/timetrack')
def add_germany_to_workregion():
"""Add GERMANY to work region enum"""
# Parse database URL
parsed = urlparse(DATABASE_URL)
# Connect to database
conn = psycopg2.connect(
host=parsed.hostname,
port=parsed.port or 5432,
user=parsed.username,
password=parsed.password,
database=parsed.path[1:] # Remove leading slash
)
try:
with conn.cursor() as cur:
print("Adding GERMANY to workregion enum...")
# Check if GERMANY already exists
cur.execute("""
SELECT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumtypid = (SELECT oid FROM pg_type WHERE typname = 'workregion')
AND enumlabel = 'GERMANY'
)
""")
if cur.fetchone()[0]:
print("GERMANY already exists in enum")
return
# Add GERMANY to the enum after UK
cur.execute("""
ALTER TYPE workregion ADD VALUE IF NOT EXISTS 'GERMANY' AFTER 'UK'
""")
print("Successfully added GERMANY to enum")
# Update any EU records that should be Germany based on other criteria
# For now, we'll leave existing EU records as is, but new records can choose Germany
# Verify the enum values
print("\nCurrent workregion enum values:")
cur.execute("""
SELECT enumlabel
FROM pg_enum
WHERE enumtypid = (SELECT oid FROM pg_type WHERE typname = 'workregion')
ORDER BY enumsortorder
""")
for row in cur.fetchall():
print(f" - {row[0]}")
# Commit changes
conn.commit()
print("\nMigration completed successfully!")
except Exception as e:
print(f"Error during migration: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
add_germany_to_workregion()

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""
Add missing columns to company_settings table
"""
import os
import psycopg2
from psycopg2 import sql
from urllib.parse import urlparse
# Get database URL from environment
DATABASE_URL = os.environ.get('DATABASE_URL', 'postgresql://timetrack:timetrack123@localhost:5432/timetrack')
def add_missing_columns():
"""Add missing columns to company_settings table"""
# Parse database URL
parsed = urlparse(DATABASE_URL)
# Connect to database
conn = psycopg2.connect(
host=parsed.hostname,
port=parsed.port or 5432,
user=parsed.username,
password=parsed.password,
database=parsed.path[1:] # Remove leading slash
)
try:
with conn.cursor() as cur:
# Check if table exists
cur.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'company_settings'
)
""")
table_exists = cur.fetchone()[0]
if not table_exists:
print("company_settings table does not exist. Creating it...")
cur.execute("""
CREATE TABLE company_settings (
id SERIAL PRIMARY KEY,
company_id INTEGER UNIQUE NOT NULL REFERENCES company(id),
work_week_start INTEGER DEFAULT 1,
work_days VARCHAR(20) DEFAULT '1,2,3,4,5',
allow_overlapping_entries BOOLEAN DEFAULT FALSE,
require_project_for_time_entry BOOLEAN DEFAULT TRUE,
allow_future_entries BOOLEAN DEFAULT FALSE,
max_hours_per_entry FLOAT DEFAULT 24.0,
enable_tasks BOOLEAN DEFAULT TRUE,
enable_sprints BOOLEAN DEFAULT FALSE,
enable_client_access BOOLEAN DEFAULT FALSE,
notify_on_overtime BOOLEAN DEFAULT TRUE,
overtime_threshold_daily FLOAT DEFAULT 8.0,
overtime_threshold_weekly FLOAT DEFAULT 40.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
print("Created company_settings table")
else:
# Check which columns exist
cur.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'company_settings'
""")
existing_columns = [row[0] for row in cur.fetchall()]
print(f"Existing columns: {existing_columns}")
# Add missing columns
columns_to_add = {
'work_week_start': 'INTEGER DEFAULT 1',
'work_days': "VARCHAR(20) DEFAULT '1,2,3,4,5'",
'allow_overlapping_entries': 'BOOLEAN DEFAULT FALSE',
'require_project_for_time_entry': 'BOOLEAN DEFAULT TRUE',
'allow_future_entries': 'BOOLEAN DEFAULT FALSE',
'max_hours_per_entry': 'FLOAT DEFAULT 24.0',
'enable_tasks': 'BOOLEAN DEFAULT TRUE',
'enable_sprints': 'BOOLEAN DEFAULT FALSE',
'enable_client_access': 'BOOLEAN DEFAULT FALSE',
'notify_on_overtime': 'BOOLEAN DEFAULT TRUE',
'overtime_threshold_daily': 'FLOAT DEFAULT 8.0',
'overtime_threshold_weekly': 'FLOAT DEFAULT 40.0',
'created_at': 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP',
'updated_at': 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP'
}
for column, definition in columns_to_add.items():
if column not in existing_columns:
print(f"Adding {column} column...")
cur.execute(f"ALTER TABLE company_settings ADD COLUMN {column} {definition}")
print(f"Added {column} column")
# Commit changes
conn.commit()
print("\nCompany settings migration completed successfully!")
except Exception as e:
print(f"Error during migration: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
add_missing_columns()

View File

@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
Fix CompanyWorkConfig field usage throughout the codebase
"""
import os
import re
from pathlib import Path
# Define old to new field mappings
FIELD_MAPPINGS = {
'work_hours_per_day': 'standard_hours_per_day',
'mandatory_break_minutes': 'break_duration_minutes',
'break_threshold_hours': 'break_after_hours',
'region': 'work_region',
}
# Fields that were removed
REMOVED_FIELDS = [
'additional_break_minutes',
'additional_break_threshold_hours',
'region_name',
'created_by_id'
]
def update_python_files():
"""Update Python files with new field names"""
python_files = [
'app.py',
'routes/company.py',
]
for filepath in python_files:
if not os.path.exists(filepath):
print(f"Skipping {filepath} - file not found")
continue
print(f"Processing {filepath}...")
with open(filepath, 'r') as f:
content = f.read()
original_content = content
# Update field references
for old_field, new_field in FIELD_MAPPINGS.items():
# Update attribute access: .old_field -> .new_field
content = re.sub(
rf'\.{old_field}\b',
f'.{new_field}',
content
)
# Update dictionary access: ['old_field'] -> ['new_field']
content = re.sub(
rf'\[[\'"]{old_field}[\'"]\]',
f"['{new_field}']",
content
)
# Update keyword arguments: old_field= -> new_field=
content = re.sub(
rf'\b{old_field}=',
f'{new_field}=',
content
)
# Handle special cases for app.py
if filepath == 'app.py':
# Update WorkRegion.GERMANY references where appropriate
content = re.sub(
r'WorkRegion\.GERMANY',
'WorkRegion.GERMANY # Note: Germany has specific labor laws',
content
)
# Handle removed fields - comment them out with explanation
for removed_field in ['additional_break_minutes', 'additional_break_threshold_hours']:
content = re.sub(
rf'^(\s*)(.*{removed_field}.*)$',
r'\1# REMOVED: \2 # This field no longer exists in the model',
content,
flags=re.MULTILINE
)
# Handle region_name specially in routes/company.py
if filepath == 'routes/company.py':
# Remove region_name assignments
content = re.sub(
r"work_config\.region_name = .*\n",
"# region_name removed - using work_region enum value instead\n",
content
)
# Fix WorkRegion.CUSTOM -> WorkRegion.OTHER
content = re.sub(
r'WorkRegion\.CUSTOM',
'WorkRegion.OTHER',
content
)
if content != original_content:
with open(filepath, 'w') as f:
f.write(content)
print(f" ✓ Updated {filepath}")
else:
print(f" - No changes needed in {filepath}")
def update_template_files():
"""Update template files with new field names"""
template_files = [
'templates/admin_company.html',
'templates/admin_work_policies.html',
'templates/config.html',
]
for filepath in template_files:
if not os.path.exists(filepath):
print(f"Skipping {filepath} - file not found")
continue
print(f"Processing {filepath}...")
with open(filepath, 'r') as f:
content = f.read()
original_content = content
# Update field references in templates
for old_field, new_field in FIELD_MAPPINGS.items():
# Update Jinja2 variable access: {{ obj.old_field }} -> {{ obj.new_field }}
content = re.sub(
r'(\{\{[^}]*\.)' + re.escape(old_field) + r'(\s*\}\})',
r'\1' + new_field + r'\2',
content
)
# Update form field names and IDs
content = re.sub(
rf'(name|id)=[\'"]{old_field}[\'"]',
rf'\1="{new_field}"',
content
)
# Handle region_name in templates
if 'region_name' in content:
# Replace region_name with work_region.value
content = re.sub(
r'(\{\{[^}]*\.)region_name(\s*\}\})',
r'\1work_region.value\2',
content
)
# Handle removed fields in admin_company.html
if filepath == 'templates/admin_company.html' and 'additional_break' in content:
# Remove entire config-item divs for removed fields
content = re.sub(
r'<div class="config-item">.*?additional_break.*?</div>\s*',
'',
content,
flags=re.DOTALL
)
if content != original_content:
with open(filepath, 'w') as f:
f.write(content)
print(f" ✓ Updated {filepath}")
else:
print(f" - No changes needed in {filepath}")
def main():
print("=== Fixing CompanyWorkConfig Field Usage ===\n")
print("1. Updating Python files...")
update_python_files()
print("\n2. Updating template files...")
update_template_files()
print("\n✅ CompanyWorkConfig migration complete!")
print("\nNote: Some fields have been removed from the model:")
print(" - additional_break_minutes")
print(" - additional_break_threshold_hours")
print(" - region_name (use work_region.value instead)")
print(" - created_by_id")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""
Fix TaskStatus enum usage throughout the codebase
"""
import os
import re
from pathlib import Path
# Define old to new status mappings
STATUS_MAPPINGS = {
'NOT_STARTED': 'TODO',
'COMPLETED': 'DONE',
'ON_HOLD': 'IN_REVIEW',
}
def update_python_files():
"""Update Python files with new TaskStatus values"""
# Find all Python files that might use TaskStatus
python_files = []
# Add specific known files
known_files = ['app.py', 'routes/tasks.py', 'routes/tasks_api.py', 'routes/sprints.py', 'routes/sprints_api.py']
python_files.extend([f for f in known_files if os.path.exists(f)])
# Search for more Python files in routes/
if os.path.exists('routes'):
python_files.extend([str(p) for p in Path('routes').glob('*.py')])
# Remove duplicates
python_files = list(set(python_files))
for filepath in python_files:
print(f"Processing {filepath}...")
with open(filepath, 'r') as f:
content = f.read()
original_content = content
# Update TaskStatus enum references
for old_status, new_status in STATUS_MAPPINGS.items():
# Update enum access: TaskStatus.OLD_STATUS -> TaskStatus.NEW_STATUS
content = re.sub(
rf'TaskStatus\.{old_status}\b',
f'TaskStatus.{new_status}',
content
)
# Update string comparisons: == 'OLD_STATUS' -> == 'NEW_STATUS'
content = re.sub(
rf"['\"]({old_status})['\"]",
f"'{new_status}'",
content
)
if content != original_content:
with open(filepath, 'w') as f:
f.write(content)
print(f" ✓ Updated {filepath}")
else:
print(f" - No changes needed in {filepath}")
def update_javascript_files():
"""Update JavaScript files with new TaskStatus values"""
js_files = []
# Find all JS files
if os.path.exists('static/js'):
js_files.extend([str(p) for p in Path('static/js').glob('*.js')])
for filepath in js_files:
print(f"Processing {filepath}...")
with open(filepath, 'r') as f:
content = f.read()
original_content = content
# Update status values in JavaScript
for old_status, new_status in STATUS_MAPPINGS.items():
# Update string literals
content = re.sub(
rf"['\"]({old_status})['\"]",
f"'{new_status}'",
content
)
# Update in case statements or object keys
content = re.sub(
rf'\b{old_status}\b:',
f'{new_status}:',
content
)
if content != original_content:
with open(filepath, 'w') as f:
f.write(content)
print(f" ✓ Updated {filepath}")
else:
print(f" - No changes needed in {filepath}")
def update_template_files():
"""Update template files with new TaskStatus values"""
template_files = []
# Find all template files that might have task status
if os.path.exists('templates'):
template_files.extend([str(p) for p in Path('templates').glob('*.html')])
for filepath in template_files:
# Skip if file doesn't contain task-related content
with open(filepath, 'r') as f:
content = f.read()
if 'task' not in content.lower() and 'status' not in content.lower():
continue
print(f"Processing {filepath}...")
original_content = content
# Update status values in templates
for old_status, new_status in STATUS_MAPPINGS.items():
# Update in option values: value="OLD_STATUS" -> value="NEW_STATUS"
content = re.sub(
rf'value=[\'"]{old_status}[\'"]',
f'value="{new_status}"',
content
)
# Update display text (be more careful here)
if old_status == 'NOT_STARTED':
content = re.sub(r'>Not Started<', '>To Do<', content)
elif old_status == 'COMPLETED':
content = re.sub(r'>Completed<', '>Done<', content)
elif old_status == 'ON_HOLD':
content = re.sub(r'>On Hold<', '>In Review<', content)
# Update in JavaScript within templates
content = re.sub(
rf"['\"]({old_status})['\"]",
f"'{new_status}'",
content
)
if content != original_content:
with open(filepath, 'w') as f:
f.write(content)
print(f" ✓ Updated {filepath}")
else:
print(f" - No changes needed in {filepath}")
def main():
print("=== Fixing TaskStatus Enum Usage ===\n")
print("1. Updating Python files...")
update_python_files()
print("\n2. Updating JavaScript files...")
update_javascript_files()
print("\n3. Updating template files...")
update_template_files()
print("\n✅ TaskStatus migration complete!")
print("\nStatus mappings applied:")
for old, new in STATUS_MAPPINGS.items():
print(f" - {old}{new}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""
Fix WorkRegion enum usage throughout the codebase
"""
import os
import re
from pathlib import Path
# Define old to new region mappings
REGION_MAPPINGS = {
'UNITED_STATES': 'USA',
'UNITED_KINGDOM': 'UK',
'FRANCE': 'EU',
'EUROPEAN_UNION': 'EU',
'CUSTOM': 'OTHER',
}
# Note: GERMANY is kept as is - it has specific labor laws
def update_python_files():
"""Update Python files with new WorkRegion values"""
python_files = []
# Add known files
known_files = ['app.py', 'routes/company.py', 'routes/system_admin.py']
python_files.extend([f for f in known_files if os.path.exists(f)])
# Search for more Python files
if os.path.exists('routes'):
python_files.extend([str(p) for p in Path('routes').glob('*.py')])
# Remove duplicates
python_files = list(set(python_files))
for filepath in python_files:
with open(filepath, 'r') as f:
content = f.read()
# Skip if no WorkRegion references
if 'WorkRegion' not in content:
continue
print(f"Processing {filepath}...")
original_content = content
# Update WorkRegion enum references
for old_region, new_region in REGION_MAPPINGS.items():
# Update enum access: WorkRegion.OLD_REGION -> WorkRegion.NEW_REGION
content = re.sub(
rf'WorkRegion\.{old_region}\b',
f'WorkRegion.{new_region}',
content
)
# Update string comparisons
content = re.sub(
rf"['\"]({old_region})['\"]",
f"'{new_region}'",
content
)
# Add comments for GERMANY usage to note it has specific laws
if 'WorkRegion.GERMANY' in content and '# Note:' not in content:
content = re.sub(
r'(WorkRegion\.GERMANY)',
r'\1 # Germany has specific labor laws beyond EU',
content,
count=1 # Only comment the first occurrence
)
if content != original_content:
with open(filepath, 'w') as f:
f.write(content)
print(f" ✓ Updated {filepath}")
else:
print(f" - No changes needed in {filepath}")
def update_template_files():
"""Update template files with new WorkRegion values"""
template_files = []
# Find relevant templates
if os.path.exists('templates'):
for template in Path('templates').glob('*.html'):
with open(template, 'r') as f:
if 'region' in f.read().lower():
template_files.append(str(template))
for filepath in template_files:
print(f"Processing {filepath}...")
with open(filepath, 'r') as f:
content = f.read()
original_content = content
# Update region values
for old_region, new_region in REGION_MAPPINGS.items():
# Update in option values
content = re.sub(
rf'value=[\'"]{old_region}[\'"]',
f'value="{new_region}"',
content
)
# Update display names
display_mappings = {
'UNITED_STATES': 'United States',
'United States': 'United States',
'UNITED_KINGDOM': 'United Kingdom',
'United Kingdom': 'United Kingdom',
'FRANCE': 'European Union',
'France': 'European Union',
'EUROPEAN_UNION': 'European Union',
'European Union': 'European Union',
'CUSTOM': 'Other',
'Custom': 'Other'
}
for old_display, new_display in display_mappings.items():
if old_display in ['France', 'FRANCE']:
# France is now part of EU
content = re.sub(
rf'>{old_display}<',
f'>{new_display}<',
content
)
if content != original_content:
with open(filepath, 'w') as f:
f.write(content)
print(f" ✓ Updated {filepath}")
else:
print(f" - No changes needed in {filepath}")
def main():
print("=== Fixing WorkRegion Enum Usage ===\n")
print("1. Updating Python files...")
update_python_files()
print("\n2. Updating template files...")
update_template_files()
print("\n✅ WorkRegion migration complete!")
print("\nRegion mappings applied:")
for old, new in REGION_MAPPINGS.items():
print(f" - {old}{new}")
print("\nNote: GERMANY remains as a separate option due to specific labor laws")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""
Fix references to removed fields throughout the codebase
"""
import os
import re
from pathlib import Path
# Fields that were removed from various models
REMOVED_FIELDS = {
'created_by_id': {
'models': ['Task', 'Project', 'Sprint', 'Announcement', 'CompanyWorkConfig'],
'replacement': 'None', # or could track via audit log
'comment': 'Field removed - consider using audit log for creator tracking'
},
'region_name': {
'models': ['CompanyWorkConfig'],
'replacement': 'work_region.value',
'comment': 'Use work_region enum value instead'
},
'additional_break_minutes': {
'models': ['CompanyWorkConfig'],
'replacement': 'None',
'comment': 'Field removed - simplified break configuration'
},
'additional_break_threshold_hours': {
'models': ['CompanyWorkConfig'],
'replacement': 'None',
'comment': 'Field removed - simplified break configuration'
}
}
def update_python_files():
"""Update Python files to handle removed fields"""
python_files = []
# Get all Python files
for root, dirs, files in os.walk('.'):
# Skip virtual environments and cache
if 'venv' in root or '__pycache__' in root or '.git' in root:
continue
for file in files:
if file.endswith('.py'):
python_files.append(os.path.join(root, file))
for filepath in python_files:
# Skip migration scripts
if 'migrations/' in filepath:
continue
with open(filepath, 'r') as f:
content = f.read()
original_content = content
modified = False
for field, info in REMOVED_FIELDS.items():
if field not in content:
continue
print(f"Processing {filepath} for {field}...")
# Handle different patterns
if field == 'created_by_id':
# Comment out lines that assign created_by_id
content = re.sub(
rf'^(\s*)([^#\n]*created_by_id\s*=\s*[^,\n]+,?)(.*)$',
rf'\1# REMOVED: \2 # {info["comment"]}\3',
content,
flags=re.MULTILINE
)
# Remove from query filters
content = re.sub(
rf'\.filter_by\(created_by_id=[^)]+\)',
'.filter_by() # REMOVED: created_by_id filter',
content
)
# Remove from dictionary accesses
content = re.sub(
rf"['\"]created_by_id['\"]\s*:\s*[^,}}]+[,}}]",
'# "created_by_id" removed from model',
content
)
elif field == 'region_name':
# Replace with work_region.value
content = re.sub(
rf'\.region_name\b',
'.work_region.value',
content
)
content = re.sub(
rf"\['region_name'\]",
"['work_region'].value",
content
)
elif field in ['additional_break_minutes', 'additional_break_threshold_hours']:
# Comment out references
content = re.sub(
rf'^(\s*)([^#\n]*{field}[^#\n]*)$',
rf'\1# REMOVED: \2 # {info["comment"]}',
content,
flags=re.MULTILINE
)
if content != original_content:
modified = True
if modified:
with open(filepath, 'w') as f:
f.write(content)
print(f" ✓ Updated {filepath}")
def update_template_files():
"""Update template files to handle removed fields"""
template_files = []
if os.path.exists('templates'):
template_files = [str(p) for p in Path('templates').glob('*.html')]
for filepath in template_files:
with open(filepath, 'r') as f:
content = f.read()
original_content = content
modified = False
for field, info in REMOVED_FIELDS.items():
if field not in content:
continue
print(f"Processing {filepath} for {field}...")
if field == 'created_by_id':
# Remove or comment out created_by references in templates
# Match {{...created_by_id...}} patterns
pattern = r'\{\{[^}]*\.created_by_id[^}]*\}\}'
content = re.sub(
pattern,
'<!-- REMOVED: created_by_id no longer available -->',
content
)
elif field == 'region_name':
# Replace with work_region.value
# Match {{...region_name...}} and replace region_name with work_region.value
pattern = r'(\{\{[^}]*\.)region_name([^}]*\}\})'
content = re.sub(
pattern,
r'\1work_region.value\2',
content
)
elif field in ['additional_break_minutes', 'additional_break_threshold_hours']:
# Remove entire form groups for these fields
pattern = r'<div[^>]*>(?:[^<]|<(?!/div))*' + re.escape(field) + r'.*?</div>\s*'
content = re.sub(
pattern,
f'<!-- REMOVED: {field} no longer in model -->\n',
content,
flags=re.DOTALL
)
if content != original_content:
modified = True
if modified:
with open(filepath, 'w') as f:
f.write(content)
print(f" ✓ Updated {filepath}")
def create_audit_log_migration():
"""Create a migration to add audit fields if needed"""
migration_content = '''#!/usr/bin/env python3
"""
Add audit log fields to replace removed created_by_id
"""
# This is a template for adding audit logging if needed
# to replace the removed created_by_id functionality
def add_audit_fields():
"""
Consider adding these fields to models that lost created_by_id:
- created_by_username (store username instead of ID)
- created_at (if not already present)
- updated_by_username
- updated_at
Or implement a separate audit log table
"""
pass
if __name__ == "__main__":
print("Consider implementing audit logging to track who created/modified records")
'''
with open('migrations/05_add_audit_fields_template.py', 'w') as f:
f.write(migration_content)
print("\n✓ Created template for audit field migration")
def main():
print("=== Fixing References to Removed Fields ===\n")
print("1. Updating Python files...")
update_python_files()
print("\n2. Updating template files...")
update_template_files()
print("\n3. Creating audit field migration template...")
create_audit_log_migration()
print("\n✅ Removed fields migration complete!")
print("\nFields handled:")
for field, info in REMOVED_FIELDS.items():
print(f" - {field}: {info['comment']}")
print("\n⚠️ Important: Review commented-out code and decide on appropriate replacements")
print(" Consider implementing audit logging for creator tracking")
if __name__ == "__main__":
main()

View File

@@ -1,7 +1,23 @@
from app import app, db
from models import User, Role
#!/usr/bin/env python3
"""
Repair user roles from string to enum values
"""
import os
import sys
import logging
# Add parent directory to path to import app
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from app import app, db
from models import User, Role
except Exception as e:
print(f"Error importing modules: {e}")
print("This migration requires Flask app context. Skipping...")
sys.exit(0)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -44,4 +60,8 @@ def repair_user_roles():
logger.info("Role repair completed")
if __name__ == "__main__":
try:
repair_user_roles()
except Exception as e:
logger.error(f"Migration failed: {e}")
sys.exit(1)

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""
Add company invitations table for email-based registration
"""
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from flask import Flask
from models import db
from sqlalchemy import text
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def migrate():
"""Add company_invitation table"""
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:////data/timetrack.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
with app.app_context():
try:
# Create company_invitation table
create_table_sql = text("""
CREATE TABLE IF NOT EXISTS company_invitation (
id SERIAL PRIMARY KEY,
company_id INTEGER NOT NULL REFERENCES company(id),
email VARCHAR(120) NOT NULL,
token VARCHAR(64) UNIQUE NOT NULL,
role VARCHAR(50) DEFAULT 'Team Member',
invited_by_id INTEGER NOT NULL REFERENCES "user"(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
accepted BOOLEAN DEFAULT FALSE,
accepted_at TIMESTAMP,
accepted_by_user_id INTEGER REFERENCES "user"(id)
);
""")
db.session.execute(create_table_sql)
# Create indexes for better performance
db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_invitation_token ON company_invitation(token);"))
db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_invitation_email ON company_invitation(email);"))
db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_invitation_company ON company_invitation(company_id);"))
db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_invitation_expires ON company_invitation(expires_at);"))
db.session.commit()
logger.info("Successfully created company_invitation table")
return True
except Exception as e:
logger.error(f"Error creating company_invitation table: {str(e)}")
db.session.rollback()
return False
if __name__ == '__main__':
success = migrate()
sys.exit(0 if success else 1)

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
Add updated_at column to company table
"""
import os
import sys
import logging
from datetime import datetime
# Add parent directory to path to import app
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import app, db
from sqlalchemy import text
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
def run_migration():
"""Add updated_at column to company table"""
with app.app_context():
try:
# Check if we're using PostgreSQL or SQLite
database_url = app.config['SQLALCHEMY_DATABASE_URI']
is_postgres = 'postgresql://' in database_url or 'postgres://' in database_url
if is_postgres:
# PostgreSQL migration
logger.info("Running PostgreSQL migration to add updated_at to company table...")
# Check if column exists
result = db.session.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'company' AND column_name = 'updated_at'
"""))
if not result.fetchone():
logger.info("Adding updated_at column to company table...")
db.session.execute(text("""
ALTER TABLE company
ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
"""))
# Update existing rows to have updated_at = created_at
db.session.execute(text("""
UPDATE company
SET updated_at = created_at
WHERE updated_at IS NULL
"""))
db.session.commit()
logger.info("Successfully added updated_at column to company table")
else:
logger.info("updated_at column already exists in company table")
else:
# SQLite migration
logger.info("Running SQLite migration to add updated_at to company table...")
# For SQLite, we need to check differently
result = db.session.execute(text("PRAGMA table_info(company)"))
columns = [row[1] for row in result.fetchall()]
if 'updated_at' not in columns:
logger.info("Adding updated_at column to company table...")
db.session.execute(text("""
ALTER TABLE company
ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
"""))
# Update existing rows to have updated_at = created_at
db.session.execute(text("""
UPDATE company
SET updated_at = created_at
WHERE updated_at IS NULL
"""))
db.session.commit()
logger.info("Successfully added updated_at column to company table")
else:
logger.info("updated_at column already exists in company table")
return True
except Exception as e:
logger.error(f"Migration failed: {e}")
db.session.rollback()
return False
if __name__ == "__main__":
success = run_migration()
sys.exit(0 if success else 1)

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
Master database migration runner
Runs all database schema migrations in the correct order
"""
import os
import sys
import subprocess
import json
from datetime import datetime
# Migration state file
MIGRATION_STATE_FILE = '/data/db_migrations_state.json'
# List of database schema migrations in order
DB_MIGRATIONS = [
'01_migrate_db.py', # SQLite schema updates (must run before data migration)
'20_add_company_updated_at.py', # Add updated_at column BEFORE data migration
'02_migrate_sqlite_to_postgres_fixed.py', # Fixed SQLite to PostgreSQL data migration
'03_add_dashboard_columns.py',
'04_add_user_preferences_columns.py',
'05_fix_task_status_enum.py',
'06_add_archived_status.py',
'07_fix_company_work_config_columns.py',
'08_fix_work_region_enum.py',
'09_add_germany_to_workregion.py',
'10_add_company_settings_columns.py',
'19_add_company_invitations.py'
]
def load_migration_state():
"""Load the migration state from file"""
if os.path.exists(MIGRATION_STATE_FILE):
try:
with open(MIGRATION_STATE_FILE, 'r') as f:
return json.load(f)
except:
return {}
return {}
def save_migration_state(state):
"""Save the migration state to file"""
os.makedirs(os.path.dirname(MIGRATION_STATE_FILE), exist_ok=True)
with open(MIGRATION_STATE_FILE, 'w') as f:
json.dump(state, f, indent=2)
def run_migration(migration_file):
"""Run a single migration script"""
script_path = os.path.join(os.path.dirname(__file__), migration_file)
if not os.path.exists(script_path):
print(f"⚠️ Migration {migration_file} not found, skipping...")
return False
print(f"\n🔄 Running migration: {migration_file}")
try:
# Run the migration script
result = subprocess.run(
[sys.executable, script_path],
capture_output=True,
text=True
)
if result.returncode == 0:
print(f"{migration_file} completed successfully")
if result.stdout:
print(result.stdout)
return True
else:
print(f"{migration_file} failed with return code {result.returncode}")
if result.stderr:
print(f"Error output: {result.stderr}")
if result.stdout:
print(f"Standard output: {result.stdout}")
return False
except Exception as e:
print(f"❌ Error running {migration_file}: {e}")
return False
def main():
"""Run all database migrations"""
print("=== Database Schema Migrations ===")
print(f"Running {len(DB_MIGRATIONS)} migrations...")
# Load migration state
state = load_migration_state()
success_count = 0
failed_count = 0
skipped_count = 0
for migration in DB_MIGRATIONS:
# Check if migration has already been run successfully
if state.get(migration, {}).get('status') == 'success':
print(f"\n⏭️ Skipping {migration} (already completed)")
skipped_count += 1
continue
# Run the migration
success = run_migration(migration)
# Update state
state[migration] = {
'status': 'success' if success else 'failed',
'timestamp': datetime.now().isoformat(),
'attempts': state.get(migration, {}).get('attempts', 0) + 1
}
if success:
success_count += 1
else:
failed_count += 1
# Don't stop on failure, continue with other migrations
print(f"⚠️ Continuing despite failure in {migration}")
# Save state after each migration
save_migration_state(state)
# Summary
print("\n" + "="*50)
print("Database Migration Summary:")
print(f"✅ Successful: {success_count}")
print(f"❌ Failed: {failed_count}")
print(f"⏭️ Skipped: {skipped_count}")
print(f"📊 Total: {len(DB_MIGRATIONS)}")
if failed_count > 0:
print("\n⚠️ Some migrations failed. Check the logs above for details.")
return 1
else:
print("\n✨ All database migrations completed successfully!")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
Run code migrations during startup - updates code to match model changes
"""
import os
import sys
import subprocess
from pathlib import Path
import hashlib
import json
from datetime import datetime
MIGRATION_STATE_FILE = '/data/code_migrations_state.json'
def get_migration_hash(script_path):
"""Get hash of migration script to detect changes"""
with open(script_path, 'rb') as f:
return hashlib.md5(f.read()).hexdigest()
def load_migration_state():
"""Load state of previously run migrations"""
if os.path.exists(MIGRATION_STATE_FILE):
try:
with open(MIGRATION_STATE_FILE, 'r') as f:
return json.load(f)
except:
return {}
return {}
def save_migration_state(state):
"""Save migration state"""
os.makedirs(os.path.dirname(MIGRATION_STATE_FILE), exist_ok=True)
with open(MIGRATION_STATE_FILE, 'w') as f:
json.dump(state, f, indent=2)
def should_run_migration(script_path, state):
"""Check if migration should run based on state"""
script_name = os.path.basename(script_path)
current_hash = get_migration_hash(script_path)
if script_name not in state:
return True
# Re-run if script has changed
if state[script_name].get('hash') != current_hash:
return True
# Skip if already run successfully
if state[script_name].get('status') == 'success':
return False
return True
def run_migration(script_path, state):
"""Run a single migration script"""
script_name = os.path.basename(script_path)
print(f"\n{'='*60}")
print(f"Running code migration: {script_name}")
print('='*60)
try:
result = subprocess.run(
[sys.executable, script_path],
capture_output=True,
text=True,
check=True,
timeout=300 # 5 minute timeout
)
print(result.stdout)
if result.stderr:
print("Warnings:", result.stderr)
# Update state
state[script_name] = {
'hash': get_migration_hash(script_path),
'status': 'success',
'last_run': str(datetime.now()),
'output': result.stdout[-1000:] if result.stdout else '' # Last 1000 chars
}
save_migration_state(state)
return True
except subprocess.CalledProcessError as e:
print(f"❌ Error running {script_name}:")
print(e.stdout)
print(e.stderr)
# Update state with failure
state[script_name] = {
'hash': get_migration_hash(script_path),
'status': 'failed',
'last_run': str(datetime.now()),
'error': str(e)
}
save_migration_state(state)
return False
except subprocess.TimeoutExpired:
print(f"❌ Migration {script_name} timed out!")
state[script_name] = {
'hash': get_migration_hash(script_path),
'status': 'timeout',
'last_run': str(datetime.now())
}
save_migration_state(state)
return False
def main():
"""Run all code migrations that need to be run"""
print("🔄 Checking for code migrations...")
# Get migration state
state = load_migration_state()
# Get all migration scripts
migrations_dir = Path(__file__).parent
migration_scripts = sorted([
str(p) for p in migrations_dir.glob('*.py')
if p.name.startswith(('11_', '12_', '13_', '14_', '15_'))
and 'template' not in p.name.lower()
])
if not migration_scripts:
print("No code migration scripts found.")
return 0
# Check which migrations need to run
to_run = []
for script in migration_scripts:
if should_run_migration(script, state):
to_run.append(script)
if not to_run:
print("✅ All code migrations are up to date.")
return 0
print(f"\n📋 Found {len(to_run)} code migrations to run:")
for script in to_run:
print(f" - {Path(script).name}")
# Run migrations
failed = []
for script in to_run:
if not run_migration(script, state):
failed.append(script)
# Continue with other migrations even if one fails
print(f"\n⚠️ Migration {Path(script).name} failed, continuing with others...")
# Summary
print("\n" + "="*60)
if failed:
print(f"⚠️ {len(failed)} code migrations failed:")
for script in failed:
print(f" - {Path(script).name}")
print("\nThe application may not work correctly.")
print("Check the logs and fix the issues.")
# Don't exit with error - let the app start anyway
return 0
else:
print("✅ All code migrations completed successfully!")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""
PostgreSQL-only migration script for TimeTrack
Applies all schema changes from commit 4214e88 onward
"""
import os
import sys
import psycopg2
from psycopg2.extras import RealDictCursor
import logging
from datetime import datetime
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class PostgresMigration:
def __init__(self, database_url):
self.database_url = database_url
self.conn = None
def connect(self):
"""Connect to PostgreSQL database"""
try:
self.conn = psycopg2.connect(self.database_url)
self.conn.autocommit = False
logger.info("Connected to PostgreSQL database")
return True
except Exception as e:
logger.error(f"Failed to connect to database: {e}")
return False
def close(self):
"""Close database connection"""
if self.conn:
self.conn.close()
def execute_migration(self, name, sql_statements):
"""Execute a migration with proper error handling"""
logger.info(f"Running migration: {name}")
cursor = self.conn.cursor()
try:
for statement in sql_statements:
if statement.strip():
cursor.execute(statement)
self.conn.commit()
logger.info(f"{name} completed successfully")
return True
except Exception as e:
self.conn.rollback()
logger.error(f"{name} failed: {e}")
return False
finally:
cursor.close()
def check_column_exists(self, table_name, column_name):
"""Check if a column exists in a table"""
cursor = self.conn.cursor()
cursor.execute("""
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = %s AND column_name = %s
)
""", (table_name, column_name))
exists = cursor.fetchone()[0]
cursor.close()
return exists
def check_table_exists(self, table_name):
"""Check if a table exists"""
cursor = self.conn.cursor()
cursor.execute("""
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = %s
)
""", (table_name,))
exists = cursor.fetchone()[0]
cursor.close()
return exists
def check_enum_value_exists(self, enum_name, value):
"""Check if an enum value exists"""
cursor = self.conn.cursor()
cursor.execute("""
SELECT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = %s
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = %s)
)
""", (value, enum_name))
exists = cursor.fetchone()[0]
cursor.close()
return exists
def run_all_migrations(self):
"""Run all migrations in order"""
if not self.connect():
return False
success = True
# 1. Add company.updated_at
if not self.check_column_exists('company', 'updated_at'):
success &= self.execute_migration("Add company.updated_at", [
"""
ALTER TABLE company
ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
""",
"""
UPDATE company SET updated_at = created_at WHERE updated_at IS NULL;
"""
])
# 2. Add user columns for 2FA and avatar
if not self.check_column_exists('user', 'two_factor_enabled'):
success &= self.execute_migration("Add user 2FA and avatar columns", [
"""
ALTER TABLE "user"
ADD COLUMN two_factor_enabled BOOLEAN DEFAULT FALSE,
ADD COLUMN two_factor_secret VARCHAR(32),
ADD COLUMN avatar_url VARCHAR(255);
"""
])
# 3. Create company_invitation table
if not self.check_table_exists('company_invitation'):
success &= self.execute_migration("Create company_invitation table", [
"""
CREATE TABLE company_invitation (
id SERIAL PRIMARY KEY,
company_id INTEGER NOT NULL REFERENCES company(id),
email VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL,
invited_by_id INTEGER REFERENCES "user"(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
used_by_id INTEGER REFERENCES "user"(id)
);
""",
"""
CREATE INDEX idx_invitation_token ON company_invitation(token);
""",
"""
CREATE INDEX idx_invitation_company ON company_invitation(company_id);
""",
"""
CREATE INDEX idx_invitation_email ON company_invitation(email);
"""
])
# 4. Add user_preferences columns
if self.check_table_exists('user_preferences'):
columns_to_add = [
('theme', 'VARCHAR(20) DEFAULT \'light\''),
('language', 'VARCHAR(10) DEFAULT \'en\''),
('timezone', 'VARCHAR(50) DEFAULT \'UTC\''),
('date_format', 'VARCHAR(20) DEFAULT \'YYYY-MM-DD\''),
('time_format', 'VARCHAR(10) DEFAULT \'24h\''),
('week_start', 'INTEGER DEFAULT 1'),
('show_weekends', 'BOOLEAN DEFAULT TRUE'),
('compact_mode', 'BOOLEAN DEFAULT FALSE'),
('email_notifications', 'BOOLEAN DEFAULT TRUE'),
('push_notifications', 'BOOLEAN DEFAULT FALSE'),
('task_reminders', 'BOOLEAN DEFAULT TRUE'),
('daily_summary', 'BOOLEAN DEFAULT FALSE'),
('weekly_report', 'BOOLEAN DEFAULT TRUE'),
('mention_notifications', 'BOOLEAN DEFAULT TRUE'),
('task_assigned_notifications', 'BOOLEAN DEFAULT TRUE'),
('task_completed_notifications', 'BOOLEAN DEFAULT FALSE'),
('sound_enabled', 'BOOLEAN DEFAULT TRUE'),
('keyboard_shortcuts', 'BOOLEAN DEFAULT TRUE'),
('auto_start_timer', 'BOOLEAN DEFAULT FALSE'),
('idle_time_detection', 'BOOLEAN DEFAULT TRUE'),
('pomodoro_enabled', 'BOOLEAN DEFAULT FALSE'),
('pomodoro_duration', 'INTEGER DEFAULT 25'),
('pomodoro_break', 'INTEGER DEFAULT 5')
]
for col_name, col_def in columns_to_add:
if not self.check_column_exists('user_preferences', col_name):
success &= self.execute_migration(f"Add user_preferences.{col_name}", [
f'ALTER TABLE user_preferences ADD COLUMN {col_name} {col_def};'
])
# 5. Add user_dashboard columns
if self.check_table_exists('user_dashboard'):
if not self.check_column_exists('user_dashboard', 'layout'):
success &= self.execute_migration("Add user_dashboard layout columns", [
"""
ALTER TABLE user_dashboard
ADD COLUMN layout JSON DEFAULT '{}',
ADD COLUMN is_locked BOOLEAN DEFAULT FALSE;
"""
])
# 6. Add company_work_config columns
if self.check_table_exists('company_work_config'):
columns_to_add = [
('standard_hours_per_day', 'FLOAT DEFAULT 8.0'),
('standard_hours_per_week', 'FLOAT DEFAULT 40.0'),
('overtime_rate', 'FLOAT DEFAULT 1.5'),
('double_time_enabled', 'BOOLEAN DEFAULT FALSE'),
('double_time_threshold', 'FLOAT DEFAULT 12.0'),
('double_time_rate', 'FLOAT DEFAULT 2.0'),
('weekly_overtime_threshold', 'FLOAT DEFAULT 40.0'),
('weekly_overtime_rate', 'FLOAT DEFAULT 1.5'),
('created_at', 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP'),
('updated_at', 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP')
]
for col_name, col_def in columns_to_add:
if not self.check_column_exists('company_work_config', col_name):
success &= self.execute_migration(f"Add company_work_config.{col_name}", [
f'ALTER TABLE company_work_config ADD COLUMN {col_name} {col_def};'
])
# 7. Add company_settings columns
if self.check_table_exists('company_settings'):
columns_to_add = [
('work_week_start', 'INTEGER DEFAULT 1'),
('work_days', 'VARCHAR(20) DEFAULT \'1,2,3,4,5\''),
('time_tracking_mode', 'VARCHAR(20) DEFAULT \'flexible\''),
('allow_manual_time', 'BOOLEAN DEFAULT TRUE'),
('require_project_selection', 'BOOLEAN DEFAULT TRUE'),
('allow_future_entries', 'BOOLEAN DEFAULT FALSE'),
('max_hours_per_entry', 'FLOAT DEFAULT 24.0'),
('min_hours_per_entry', 'FLOAT DEFAULT 0.0'),
('round_time_to', 'INTEGER DEFAULT 1'),
('auto_break_deduction', 'BOOLEAN DEFAULT FALSE'),
('allow_overlapping_entries', 'BOOLEAN DEFAULT FALSE'),
('require_daily_notes', 'BOOLEAN DEFAULT FALSE'),
('enable_tasks', 'BOOLEAN DEFAULT TRUE'),
('enable_projects', 'BOOLEAN DEFAULT TRUE'),
('enable_teams', 'BOOLEAN DEFAULT TRUE'),
('enable_reports', 'BOOLEAN DEFAULT TRUE'),
('enable_invoicing', 'BOOLEAN DEFAULT FALSE'),
('enable_client_access', 'BOOLEAN DEFAULT FALSE'),
('default_currency', 'VARCHAR(3) DEFAULT \'USD\''),
('created_at', 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP'),
('updated_at', 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP')
]
for col_name, col_def in columns_to_add:
if not self.check_column_exists('company_settings', col_name):
success &= self.execute_migration(f"Add company_settings.{col_name}", [
f'ALTER TABLE company_settings ADD COLUMN {col_name} {col_def};'
])
# 8. Add dashboard_widget columns
if self.check_table_exists('dashboard_widget'):
if not self.check_column_exists('dashboard_widget', 'config'):
success &= self.execute_migration("Add dashboard_widget config columns", [
"""
ALTER TABLE dashboard_widget
ADD COLUMN config JSON DEFAULT '{}',
ADD COLUMN is_visible BOOLEAN DEFAULT TRUE;
"""
])
# 9. Update WorkRegion enum
if not self.check_enum_value_exists('workregion', 'GERMANY'):
success &= self.execute_migration("Add GERMANY to WorkRegion enum", [
"""
ALTER TYPE workregion ADD VALUE IF NOT EXISTS 'GERMANY';
"""
])
# 10. Update TaskStatus enum
if not self.check_enum_value_exists('taskstatus', 'ARCHIVED'):
success &= self.execute_migration("Add ARCHIVED to TaskStatus enum", [
"""
ALTER TYPE taskstatus ADD VALUE IF NOT EXISTS 'ARCHIVED';
"""
])
# 11. Update WidgetType enum
widget_types_to_add = [
'REVENUE_CHART', 'EXPENSE_CHART', 'PROFIT_CHART', 'CASH_FLOW',
'INVOICE_STATUS', 'CLIENT_LIST', 'PROJECT_BUDGET', 'TEAM_CAPACITY',
'SPRINT_BURNDOWN', 'VELOCITY_CHART', 'BACKLOG_STATUS', 'RELEASE_TIMELINE',
'CODE_COMMITS', 'BUILD_STATUS', 'DEPLOYMENT_HISTORY', 'ERROR_RATE',
'SYSTEM_HEALTH', 'USER_ACTIVITY', 'SECURITY_ALERTS', 'AUDIT_LOG'
]
for widget_type in widget_types_to_add:
if not self.check_enum_value_exists('widgettype', widget_type):
success &= self.execute_migration(f"Add {widget_type} to WidgetType enum", [
f"ALTER TYPE widgettype ADD VALUE IF NOT EXISTS '{widget_type}';"
])
self.close()
if success:
logger.info("\n✅ All migrations completed successfully!")
else:
logger.error("\n❌ Some migrations failed. Check the logs above.")
return success
def main():
"""Main migration function"""
# Get database URL from environment
database_url = os.environ.get('DATABASE_URL')
if not database_url:
logger.error("DATABASE_URL environment variable not set")
return 1
# Run migrations
migration = PostgresMigration(database_url)
success = migration.run_all_migrations()
return 0 if success else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""
PostgreSQL-only migration runner
Manages migration state and runs migrations in order
"""
import os
import sys
import json
import subprocess
from datetime import datetime
from pathlib import Path
# Migration state file
MIGRATION_STATE_FILE = '/data/postgres_migrations_state.json'
# List of PostgreSQL migrations in order
POSTGRES_MIGRATIONS = [
'postgres_only_migration.py', # Main migration from commit 4214e88 onward
]
def load_migration_state():
"""Load the migration state from file"""
if os.path.exists(MIGRATION_STATE_FILE):
try:
with open(MIGRATION_STATE_FILE, 'r') as f:
return json.load(f)
except:
return {}
return {}
def save_migration_state(state):
"""Save the migration state to file"""
os.makedirs(os.path.dirname(MIGRATION_STATE_FILE), exist_ok=True)
with open(MIGRATION_STATE_FILE, 'w') as f:
json.dump(state, f, indent=2)
def run_migration(migration_file):
"""Run a single migration script"""
script_path = os.path.join(os.path.dirname(__file__), migration_file)
if not os.path.exists(script_path):
print(f"⚠️ Migration {migration_file} not found, skipping...")
return False
print(f"\n🔄 Running migration: {migration_file}")
try:
# Run the migration script
result = subprocess.run(
[sys.executable, script_path],
capture_output=True,
text=True
)
if result.returncode == 0:
print(f"{migration_file} completed successfully")
if result.stdout:
print(result.stdout)
return True
else:
print(f"{migration_file} failed with return code {result.returncode}")
if result.stderr:
print(f"Error output: {result.stderr}")
if result.stdout:
print(f"Standard output: {result.stdout}")
return False
except Exception as e:
print(f"❌ Error running {migration_file}: {e}")
return False
def main():
"""Run all PostgreSQL migrations"""
print("=== PostgreSQL Database Migrations ===")
print(f"Running {len(POSTGRES_MIGRATIONS)} migrations...")
# Load migration state
state = load_migration_state()
success_count = 0
failed_count = 0
skipped_count = 0
for migration in POSTGRES_MIGRATIONS:
# Check if migration has already been run successfully
if state.get(migration, {}).get('status') == 'success':
print(f"\n⏭️ Skipping {migration} (already completed)")
skipped_count += 1
continue
# Run the migration
success = run_migration(migration)
# Update state
state[migration] = {
'status': 'success' if success else 'failed',
'timestamp': datetime.now().isoformat(),
'attempts': state.get(migration, {}).get('attempts', 0) + 1
}
if success:
success_count += 1
else:
failed_count += 1
# Save state after each migration
save_migration_state(state)
# Summary
print("\n" + "="*50)
print("PostgreSQL Migration Summary:")
print(f"✅ Successful: {success_count}")
print(f"❌ Failed: {failed_count}")
print(f"⏭️ Skipped: {skipped_count}")
print(f"📊 Total: {len(POSTGRES_MIGRATIONS)}")
if failed_count > 0:
print("\n⚠️ Some migrations failed. Check the logs above for details.")
return 1
else:
print("\n✨ All PostgreSQL migrations completed successfully!")
return 0
if __name__ == "__main__":
sys.exit(main())

51
models/__init__.py Normal file
View File

@@ -0,0 +1,51 @@
"""
Models package for TimeTrack application.
Split from monolithic models.py into domain-specific modules.
"""
from flask_sqlalchemy import SQLAlchemy
# Initialize SQLAlchemy instance
db = SQLAlchemy()
# Import all models to maintain backward compatibility
from .enums import (
Role, AccountType, WorkRegion, CommentVisibility,
TaskStatus, TaskPriority, SprintStatus, WidgetType, WidgetSize
)
from .company import Company, CompanySettings, CompanyWorkConfig
from .user import User, UserPreferences, UserDashboard
from .team import Team
from .project import Project, ProjectCategory
from .task import Task, TaskDependency, SubTask, Comment
from .time_entry import TimeEntry
from .sprint import Sprint
from .system import SystemSettings, BrandingSettings, SystemEvent
from .announcement import Announcement
from .dashboard import DashboardWidget, WidgetTemplate
from .work_config import WorkConfig
from .invitation import CompanyInvitation
from .note import Note, NoteVisibility, NoteLink, NoteFolder
# Make all models available at package level
__all__ = [
'db',
# Enums
'Role', 'AccountType', 'WorkRegion', 'CommentVisibility',
'TaskStatus', 'TaskPriority', 'SprintStatus', 'WidgetType', 'WidgetSize',
# Models
'Company', 'CompanySettings', 'CompanyWorkConfig',
'User', 'UserPreferences', 'UserDashboard',
'Team',
'Project', 'ProjectCategory',
'Task', 'TaskDependency', 'SubTask', 'Comment',
'TimeEntry',
'Sprint',
'SystemSettings', 'BrandingSettings', 'SystemEvent',
'Announcement',
'DashboardWidget', 'WidgetTemplate',
'WorkConfig',
'CompanyInvitation',
'Note', 'NoteVisibility', 'NoteLink', 'NoteFolder'
]

91
models/announcement.py Normal file
View File

@@ -0,0 +1,91 @@
"""
Announcement model for system-wide notifications
"""
from datetime import datetime
import json
from . import db
class Announcement(db.Model):
"""System-wide announcements"""
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text, nullable=False)
# Announcement properties
is_active = db.Column(db.Boolean, default=True)
is_urgent = db.Column(db.Boolean, default=False) # For urgent announcements with different styling
announcement_type = db.Column(db.String(20), default='info') # info, warning, success, danger
# Scheduling
start_date = db.Column(db.DateTime, nullable=True) # When to start showing
end_date = db.Column(db.DateTime, nullable=True) # When to stop showing
# Targeting
target_all_users = db.Column(db.Boolean, default=True)
target_roles = db.Column(db.Text, nullable=True) # JSON string of roles if not all users
target_companies = db.Column(db.Text, nullable=True) # JSON string of company IDs if not all companies
# 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
created_by = db.relationship('User', foreign_keys=[created_by_id])
def __repr__(self):
return f'<Announcement {self.title}>'
def is_visible_now(self):
"""Check if announcement should be visible at current time"""
if not self.is_active:
return False
now = datetime.now()
# Check start date
if self.start_date and now < self.start_date:
return False
# Check end date
if self.end_date and now > self.end_date:
return False
return True
def is_visible_to_user(self, user):
"""Check if announcement should be visible to specific user"""
if not self.is_visible_now():
return False
# If targeting all users, show to everyone
if self.target_all_users:
return True
# Check role targeting
if self.target_roles:
try:
target_roles = json.loads(self.target_roles)
if user.role.value not in target_roles:
return False
except (json.JSONDecodeError, AttributeError):
pass
# Check company targeting
if self.target_companies:
try:
target_companies = json.loads(self.target_companies)
if user.company_id not in target_companies:
return False
except (json.JSONDecodeError, AttributeError):
pass
return True
@staticmethod
def get_active_announcements_for_user(user):
"""Get all active announcements visible to a specific user"""
announcements = Announcement.query.filter_by(is_active=True).all()
return [ann for ann in announcements if ann.is_visible_to_user(user)]

20
models/base.py Normal file
View File

@@ -0,0 +1,20 @@
"""
Base model utilities and mixins
"""
from datetime import datetime
from sqlalchemy.ext.declarative import declared_attr
from . import db
class TimestampMixin:
"""Mixin for adding created_at and updated_at timestamps"""
created_at = db.Column(db.DateTime, default=datetime.now, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
class CompanyScopedMixin:
"""Mixin for models that belong to a company"""
@declared_attr
def company_id(cls):
return db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)

232
models/company.py Normal file
View File

@@ -0,0 +1,232 @@
"""
Company-related models
"""
from datetime import datetime
from . import db
from .enums import WorkRegion
class Company(db.Model):
"""Company model for multi-tenancy"""
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True)
slug = db.Column(db.String(50), unique=True, nullable=False) # URL-friendly identifier
description = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=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
# Relationships
users = db.relationship('User', backref='company', lazy=True)
teams = db.relationship('Team', backref='company', lazy=True)
projects = db.relationship('Project', backref='company', lazy=True)
def __repr__(self):
return f'<Company {self.name}>'
def generate_slug(self):
"""Generate URL-friendly slug from company name"""
import re
slug = re.sub(r'[^\w\s-]', '', self.name.lower())
slug = re.sub(r'[-\s]+', '-', slug)
return slug.strip('-')
class CompanySettings(db.Model):
"""Company-specific settings"""
id = db.Column(db.Integer, primary_key=True)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), unique=True, nullable=False)
# Work week settings
work_week_start = db.Column(db.Integer, default=1) # 1 = Monday, 7 = Sunday
work_days = db.Column(db.String(20), default='1,2,3,4,5') # Comma-separated day numbers
# Time tracking settings
allow_overlapping_entries = db.Column(db.Boolean, default=False)
require_project_for_time_entry = db.Column(db.Boolean, default=True)
allow_future_entries = db.Column(db.Boolean, default=False)
max_hours_per_entry = db.Column(db.Float, default=24.0)
# Feature toggles
enable_tasks = db.Column(db.Boolean, default=True)
enable_sprints = db.Column(db.Boolean, default=False)
enable_client_access = db.Column(db.Boolean, default=False)
# Notification settings
notify_on_overtime = db.Column(db.Boolean, default=True)
overtime_threshold_daily = db.Column(db.Float, default=8.0)
overtime_threshold_weekly = db.Column(db.Float, default=40.0)
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
# Relationship
company = db.relationship('Company', backref=db.backref('settings', uselist=False))
@classmethod
def get_or_create(cls, company_id):
"""Get existing settings or create default ones"""
settings = cls.query.filter_by(company_id=company_id).first()
if not settings:
settings = cls(company_id=company_id)
db.session.add(settings)
db.session.commit()
return settings
class CompanyWorkConfig(db.Model):
"""Company-specific work configuration"""
id = db.Column(db.Integer, primary_key=True)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Work hours configuration
standard_hours_per_day = db.Column(db.Float, default=8.0)
standard_hours_per_week = db.Column(db.Float, default=40.0)
# Work region for compliance
work_region = db.Column(db.Enum(WorkRegion), default=WorkRegion.OTHER)
# Overtime rules
overtime_enabled = db.Column(db.Boolean, default=True)
overtime_rate = db.Column(db.Float, default=1.5) # 1.5x regular rate
double_time_enabled = db.Column(db.Boolean, default=False)
double_time_threshold = db.Column(db.Float, default=12.0) # Hours after which double time applies
double_time_rate = db.Column(db.Float, default=2.0)
# Break rules
require_breaks = db.Column(db.Boolean, default=True)
break_duration_minutes = db.Column(db.Integer, default=30)
break_after_hours = db.Column(db.Float, default=6.0)
# Weekly overtime rules
weekly_overtime_threshold = db.Column(db.Float, default=40.0)
weekly_overtime_rate = db.Column(db.Float, default=1.5)
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
company = db.relationship('Company', backref='work_configs')
def __repr__(self):
return f'<CompanyWorkConfig {self.company.name if self.company else "Unknown"}: {self.work_region.value if self.work_region else "No region"}>'
@classmethod
def get_regional_preset(cls, region):
"""Get regional preset configuration."""
presets = {
WorkRegion.EU: {
'standard_hours_per_day': 8.0,
'standard_hours_per_week': 40.0,
'overtime_enabled': True,
'overtime_rate': 1.5,
'double_time_enabled': False,
'double_time_threshold': 12.0,
'double_time_rate': 2.0,
'require_breaks': True,
'break_duration_minutes': 30,
'break_after_hours': 6.0,
'weekly_overtime_threshold': 40.0,
'weekly_overtime_rate': 1.5,
'region_name': 'European Union'
},
WorkRegion.GERMANY: {
'standard_hours_per_day': 8.0,
'standard_hours_per_week': 40.0,
'overtime_enabled': True,
'overtime_rate': 1.25, # German overtime is typically 25% extra
'double_time_enabled': False,
'double_time_threshold': 10.0,
'double_time_rate': 1.5,
'require_breaks': True,
'break_duration_minutes': 30, # 30 min break after 6 hours
'break_after_hours': 6.0,
'weekly_overtime_threshold': 48.0, # German law allows up to 48 hours/week
'weekly_overtime_rate': 1.25,
'region_name': 'Germany'
},
WorkRegion.USA: {
'standard_hours_per_day': 8.0,
'standard_hours_per_week': 40.0,
'overtime_enabled': True,
'overtime_rate': 1.5,
'double_time_enabled': False,
'double_time_threshold': 12.0,
'double_time_rate': 2.0,
'require_breaks': False, # No federal requirement
'break_duration_minutes': 0,
'break_after_hours': 999.0, # Effectively disabled
'weekly_overtime_threshold': 40.0,
'weekly_overtime_rate': 1.5,
'region_name': 'United States'
},
WorkRegion.UK: {
'standard_hours_per_day': 8.0,
'standard_hours_per_week': 48.0, # UK has 48-hour week limit
'overtime_enabled': True,
'overtime_rate': 1.5,
'double_time_enabled': False,
'double_time_threshold': 12.0,
'double_time_rate': 2.0,
'require_breaks': True,
'break_duration_minutes': 20,
'break_after_hours': 6.0,
'weekly_overtime_threshold': 48.0,
'weekly_overtime_rate': 1.5,
'region_name': 'United Kingdom'
},
WorkRegion.CANADA: {
'standard_hours_per_day': 8.0,
'standard_hours_per_week': 40.0,
'overtime_enabled': True,
'overtime_rate': 1.5,
'double_time_enabled': False,
'double_time_threshold': 12.0,
'double_time_rate': 2.0,
'require_breaks': True,
'break_duration_minutes': 30,
'break_after_hours': 5.0,
'weekly_overtime_threshold': 40.0,
'weekly_overtime_rate': 1.5,
'region_name': 'Canada'
},
WorkRegion.AUSTRALIA: {
'standard_hours_per_day': 7.6, # 38-hour week / 5 days
'standard_hours_per_week': 38.0,
'overtime_enabled': True,
'overtime_rate': 1.5,
'double_time_enabled': True,
'double_time_threshold': 10.0,
'double_time_rate': 2.0,
'require_breaks': True,
'break_duration_minutes': 30,
'break_after_hours': 5.0,
'weekly_overtime_threshold': 38.0,
'weekly_overtime_rate': 1.5,
'region_name': 'Australia'
},
WorkRegion.OTHER: {
'standard_hours_per_day': 8.0,
'standard_hours_per_week': 40.0,
'overtime_enabled': False,
'overtime_rate': 1.5,
'double_time_enabled': False,
'double_time_threshold': 12.0,
'double_time_rate': 2.0,
'require_breaks': False,
'break_duration_minutes': 0,
'break_after_hours': 999.0,
'weekly_overtime_threshold': 40.0,
'weekly_overtime_rate': 1.5,
'region_name': 'Other'
}
}
return presets.get(region, presets[WorkRegion.OTHER])

99
models/dashboard.py Normal file
View File

@@ -0,0 +1,99 @@
"""
Dashboard widget models
"""
from datetime import datetime
import json
from . import db
from .enums import WidgetType, Role
class DashboardWidget(db.Model):
"""User dashboard widget configuration"""
id = db.Column(db.Integer, primary_key=True)
dashboard_id = db.Column(db.Integer, db.ForeignKey('user_dashboard.id'), nullable=False)
widget_type = db.Column(db.Enum(WidgetType), nullable=False)
# Grid position and size
grid_x = db.Column(db.Integer, nullable=False, default=0) # X position in grid
grid_y = db.Column(db.Integer, nullable=False, default=0) # Y position in grid
grid_width = db.Column(db.Integer, nullable=False, default=1) # Width in grid units
grid_height = db.Column(db.Integer, nullable=False, default=1) # Height in grid units
# Widget configuration
title = db.Column(db.String(100)) # Custom widget title
config = db.Column(db.Text) # JSON string for widget-specific configuration
refresh_interval = db.Column(db.Integer, default=60) # Refresh interval in seconds
# Widget state
is_visible = db.Column(db.Boolean, default=True)
is_minimized = db.Column(db.Boolean, default=False)
z_index = db.Column(db.Integer, default=1) # Stacking order
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
def __repr__(self):
return f'<DashboardWidget {self.widget_type.value} ({self.grid_width}x{self.grid_height})>'
@property
def config_dict(self):
"""Parse widget configuration JSON"""
if self.config:
try:
return json.loads(self.config)
except:
return {}
return {}
@config_dict.setter
def config_dict(self, value):
"""Set widget configuration as JSON"""
self.config = json.dumps(value) if value else None
class WidgetTemplate(db.Model):
"""Pre-defined widget templates for easy dashboard setup"""
id = db.Column(db.Integer, primary_key=True)
widget_type = db.Column(db.Enum(WidgetType), nullable=False)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
icon = db.Column(db.String(50)) # Icon name or emoji
# Default configuration
default_width = db.Column(db.Integer, default=1)
default_height = db.Column(db.Integer, default=1)
default_config = db.Column(db.Text) # JSON string for default widget configuration
# Access control
required_role = db.Column(db.Enum(Role), default=Role.TEAM_MEMBER)
is_active = db.Column(db.Boolean, default=True)
# Categories for organization
category = db.Column(db.String(50), default='General') # Time, Projects, Tasks, Analytics, Team, Actions
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
def __repr__(self):
return f'<WidgetTemplate {self.name} ({self.widget_type.value})>'
def can_user_access(self, user):
"""Check if user has required role to use this widget"""
if not self.is_active:
return False
# Define role hierarchy
role_hierarchy = {
Role.TEAM_MEMBER: 1,
Role.TEAM_LEADER: 2,
Role.SUPERVISOR: 3,
Role.ADMIN: 4,
Role.SYSTEM_ADMIN: 5
}
user_level = role_hierarchy.get(user.role, 0)
required_level = role_hierarchy.get(self.required_role, 0)
return user_level >= required_level

115
models/enums.py Normal file
View File

@@ -0,0 +1,115 @@
"""
Enum definitions for the TimeTrack application
"""
import enum
class Role(enum.Enum):
"""User role enumeration"""
TEAM_MEMBER = "Team Member"
TEAM_LEADER = "Team Leader"
SUPERVISOR = "Supervisor"
ADMIN = "Administrator" # Company-level admin
SYSTEM_ADMIN = "System Administrator" # System-wide admin
class AccountType(enum.Enum):
"""Account type for freelancer support"""
COMPANY_USER = "Company User"
FREELANCER = "Freelancer"
class WorkRegion(enum.Enum):
"""Work region enumeration for different labor law compliance"""
USA = "United States"
CANADA = "Canada"
UK = "United Kingdom"
GERMANY = "Germany"
EU = "European Union"
AUSTRALIA = "Australia"
OTHER = "Other"
class CommentVisibility(enum.Enum):
"""Comment visibility levels"""
PRIVATE = "Private" # Only creator can see
TEAM = "Team" # Team members can see
COMPANY = "Company" # All company users can see
class TaskStatus(enum.Enum):
"""Task status enumeration"""
TODO = "To Do"
IN_PROGRESS = "In Progress"
IN_REVIEW = "In Review"
DONE = "Done"
CANCELLED = "Cancelled"
ARCHIVED = "Archived"
class TaskPriority(enum.Enum):
"""Task priority levels"""
LOW = "Low"
MEDIUM = "Medium"
HIGH = "High"
URGENT = "Urgent"
class SprintStatus(enum.Enum):
"""Sprint status enumeration"""
PLANNING = "Planning"
ACTIVE = "Active"
COMPLETED = "Completed"
CANCELLED = "Cancelled"
class WidgetType(enum.Enum):
"""Dashboard widget types"""
# Time Tracking Widgets
CURRENT_TIMER = "current_timer"
DAILY_SUMMARY = "daily_summary"
WEEKLY_CHART = "weekly_chart"
BREAK_REMINDER = "break_reminder"
TIME_SUMMARY = "Time Summary"
# Project Management Widgets
ACTIVE_PROJECTS = "active_projects"
PROJECT_PROGRESS = "project_progress"
PROJECT_ACTIVITY = "project_activity"
PROJECT_DEADLINES = "project_deadlines"
PROJECT_STATUS = "Project Status"
# Task Management Widgets
ASSIGNED_TASKS = "assigned_tasks"
TASK_PRIORITY = "task_priority"
TASK_CALENDAR = "task_calendar"
UPCOMING_TASKS = "upcoming_tasks"
TASK_LIST = "Task List"
# Sprint Widgets
SPRINT_OVERVIEW = "sprint_overview"
SPRINT_BURNDOWN = "sprint_burndown"
SPRINT_PROGRESS = "Sprint Progress"
# Team & Analytics Widgets
TEAM_WORKLOAD = "team_workload"
TEAM_PRESENCE = "team_presence"
TEAM_ACTIVITY = "Team Activity"
# Performance & Stats Widgets
PRODUCTIVITY_STATS = "productivity_stats"
TIME_DISTRIBUTION = "time_distribution"
PERSONAL_STATS = "Personal Stats"
# Action Widgets
QUICK_ACTIONS = "quick_actions"
RECENT_ACTIVITY = "recent_activity"
class WidgetSize(enum.Enum):
"""Dashboard widget sizes"""
SMALL = "small" # 1x1
MEDIUM = "medium" # 2x1
LARGE = "large" # 2x2
WIDE = "wide" # 3x1 or full width

56
models/invitation.py Normal file
View File

@@ -0,0 +1,56 @@
"""
Invitation model for company email invites
"""
from datetime import datetime, timedelta
from . import db
import secrets
import string
class CompanyInvitation(db.Model):
"""Company invitation model for email-based registration"""
id = db.Column(db.Integer, primary_key=True)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
email = db.Column(db.String(120), nullable=False)
token = db.Column(db.String(64), unique=True, nullable=False)
role = db.Column(db.String(50), default='Team Member') # Role to assign when accepted
# Invitation metadata
invited_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.now)
expires_at = db.Column(db.DateTime, nullable=False)
# Status tracking
accepted = db.Column(db.Boolean, default=False)
accepted_at = db.Column(db.DateTime)
accepted_by_user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
# Relationships
company = db.relationship('Company', backref='invitations')
invited_by = db.relationship('User', foreign_keys=[invited_by_id], backref='sent_invitations')
accepted_by = db.relationship('User', foreign_keys=[accepted_by_user_id], backref='accepted_invitation')
def __init__(self, **kwargs):
super(CompanyInvitation, self).__init__(**kwargs)
if not self.token:
self.token = self.generate_token()
if not self.expires_at:
self.expires_at = datetime.now() + timedelta(days=7) # 7 days expiry
@staticmethod
def generate_token():
"""Generate a secure random token"""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(32))
def is_expired(self):
"""Check if invitation has expired"""
return datetime.now() > self.expires_at
def is_valid(self):
"""Check if invitation is valid (not accepted and not expired)"""
return not self.accepted and not self.is_expired()
def __repr__(self):
return f'<CompanyInvitation {self.email} to {self.company.name}>'

278
models/note.py Normal file
View File

@@ -0,0 +1,278 @@
"""
Note models for markdown-based documentation and knowledge management.
Migrated from models_old.py to maintain consistency with the new modular structure.
"""
import enum
import re
from datetime import datetime
from sqlalchemy import UniqueConstraint
from . import db
from .enums import Role
class NoteVisibility(enum.Enum):
"""Note sharing visibility levels"""
PRIVATE = "Private"
TEAM = "Team"
COMPANY = "Company"
class Note(db.Model):
"""Markdown notes with sharing capabilities"""
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text, nullable=False) # Markdown content
slug = db.Column(db.String(100), nullable=False) # URL-friendly identifier
# Visibility and sharing
visibility = db.Column(db.Enum(NoteVisibility), nullable=False, default=NoteVisibility.PRIVATE)
# Folder organization
folder = db.Column(db.String(100), nullable=True) # Folder path like "Work/Projects" or "Personal"
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
# Associations
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Optional associations
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True) # For team-specific notes
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True) # Link to project
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True) # Link to task
# Tags for organization
tags = db.Column(db.String(500)) # Comma-separated tags
# Pin important notes
is_pinned = db.Column(db.Boolean, default=False)
# Soft delete
is_archived = db.Column(db.Boolean, default=False)
archived_at = db.Column(db.DateTime, nullable=True)
# Relationships
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='notes')
company = db.relationship('Company', backref='notes')
team = db.relationship('Team', backref='notes')
project = db.relationship('Project', backref='notes')
task = db.relationship('Task', backref='notes')
# Unique constraint on slug per company
__table_args__ = (db.UniqueConstraint('company_id', 'slug', name='uq_note_slug_per_company'),)
def __repr__(self):
return f'<Note {self.title}>'
def generate_slug(self):
"""Generate URL-friendly slug from title and set it on the model"""
import re
# Remove special characters and convert to lowercase
slug = re.sub(r'[^\w\s-]', '', self.title.lower())
# Replace spaces with hyphens
slug = re.sub(r'[-\s]+', '-', slug)
# Remove leading/trailing hyphens
slug = slug.strip('-')
# Ensure uniqueness within company
base_slug = slug
counter = 1
while Note.query.filter_by(company_id=self.company_id, slug=slug).filter(Note.id != self.id).first():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
return slug
def can_user_view(self, user):
"""Check if user can view this note"""
# Creator can always view
if user.id == self.created_by_id:
return True
# Check company match
if user.company_id != self.company_id:
return False
# Check visibility
if self.visibility == NoteVisibility.COMPANY:
return True
elif self.visibility == NoteVisibility.TEAM:
# Check if user is in the same team
if self.team_id and user.team_id == self.team_id:
return True
# Admins can view all team notes
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
return True
return False
def can_user_edit(self, user):
"""Check if user can edit this note"""
# Creator can always edit
if user.id == self.created_by_id:
return True
# Admins can edit company notes
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN] and user.company_id == self.company_id:
return True
return False
def get_tags_list(self):
"""Get tags as a list"""
if not self.tags:
return []
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
def set_tags_list(self, tags_list):
"""Set tags from a list"""
self.tags = ','.join(tags_list) if tags_list else None
def get_preview(self, length=200):
"""Get a plain text preview of the note content"""
# Strip markdown formatting for preview
import re
from frontmatter_utils import parse_frontmatter
# Extract body content without frontmatter
_, body = parse_frontmatter(self.content)
text = body
# Remove headers
text = re.sub(r'^#+\s+', '', text, flags=re.MULTILINE)
# Remove emphasis
text = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text)
text = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text)
# Remove links
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
# Remove code blocks
text = re.sub(r'```[^`]*```', '', text, flags=re.DOTALL)
text = re.sub(r'`([^`]+)`', r'\1', text)
# Clean up whitespace
text = ' '.join(text.split())
if len(text) > length:
return text[:length] + '...'
return text
def render_html(self):
"""Render markdown content to HTML"""
try:
import markdown
from frontmatter_utils import parse_frontmatter
# Extract body content without frontmatter
_, body = parse_frontmatter(self.content)
# Use extensions for better markdown support
html = markdown.markdown(body, extensions=['extra', 'codehilite', 'toc'])
return html
except ImportError:
# Fallback if markdown not installed
return f'<pre>{self.content}</pre>'
def get_frontmatter(self):
"""Get frontmatter metadata from content"""
from frontmatter_utils import parse_frontmatter
metadata, _ = parse_frontmatter(self.content)
return metadata
def update_frontmatter(self):
"""Update content with current metadata as frontmatter"""
from frontmatter_utils import update_frontmatter
metadata = {
'title': self.title,
'visibility': self.visibility.value.lower(),
'folder': self.folder,
'tags': self.get_tags_list() if self.tags else None,
'project': self.project.code if self.project else None,
'task_id': self.task_id,
'pinned': self.is_pinned if self.is_pinned else None,
'created': self.created_at.isoformat() if self.created_at else None,
'updated': self.updated_at.isoformat() if self.updated_at else None,
'author': self.created_by.username if self.created_by else None
}
# Remove None values
metadata = {k: v for k, v in metadata.items() if v is not None}
self.content = update_frontmatter(self.content, metadata)
def sync_from_frontmatter(self):
"""Update model fields from frontmatter in content"""
from frontmatter_utils import parse_frontmatter
metadata, _ = parse_frontmatter(self.content)
if metadata:
# Update fields from frontmatter
if 'title' in metadata:
self.title = metadata['title']
if 'visibility' in metadata:
try:
self.visibility = NoteVisibility[metadata['visibility'].upper()]
except KeyError:
pass
if 'folder' in metadata:
self.folder = metadata['folder']
if 'tags' in metadata:
if isinstance(metadata['tags'], list):
self.set_tags_list(metadata['tags'])
elif isinstance(metadata['tags'], str):
self.tags = metadata['tags']
if 'pinned' in metadata:
self.is_pinned = bool(metadata['pinned'])
class NoteLink(db.Model):
"""Links between notes for creating relationships"""
id = db.Column(db.Integer, primary_key=True)
# Source and target notes with cascade deletion
source_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
target_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
# Link metadata
link_type = db.Column(db.String(50), default='related') # related, parent, child, etc.
created_at = db.Column(db.DateTime, default=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships with cascade deletion
source_note = db.relationship('Note', foreign_keys=[source_note_id],
backref=db.backref('outgoing_links', cascade='all, delete-orphan'))
target_note = db.relationship('Note', foreign_keys=[target_note_id],
backref=db.backref('incoming_links', cascade='all, delete-orphan'))
created_by = db.relationship('User', foreign_keys=[created_by_id])
# Unique constraint to prevent duplicate links
__table_args__ = (db.UniqueConstraint('source_note_id', 'target_note_id', name='uq_note_link'),)
def __repr__(self):
return f'<NoteLink {self.source_note_id} -> {self.target_note_id}>'
class NoteFolder(db.Model):
"""Represents a folder for organizing notes"""
id = db.Column(db.Integer, primary_key=True)
# Folder properties
name = db.Column(db.String(100), nullable=False)
path = db.Column(db.String(500), nullable=False) # Full path like "Work/Projects/Q1"
parent_path = db.Column(db.String(500), nullable=True) # Parent folder path
description = db.Column(db.Text, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Relationships
created_by = db.relationship('User', foreign_keys=[created_by_id])
company = db.relationship('Company', foreign_keys=[company_id])
# Unique constraint to prevent duplicate paths within a company
__table_args__ = (db.UniqueConstraint('path', 'company_id', name='uq_folder_path_company'),)
def __repr__(self):
return f'<NoteFolder {self.path}>'

86
models/project.py Normal file
View File

@@ -0,0 +1,86 @@
"""
Project-related models
"""
from datetime import datetime
from . import db
from .enums import Role
class Project(db.Model):
"""Project model for time tracking"""
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text, nullable=True)
code = db.Column(db.String(20), nullable=False) # Project code (e.g., PRJ001)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
# Company association for multi-tenancy
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Foreign key to user who created the project (Admin/Supervisor)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# 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)
# Relationships
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'),)
def __repr__(self):
return f'<Project {self.name} ({self.code})>'
def is_user_allowed(self, user):
"""Check if a user can log time to this project"""
# User must be in the same company
if user.company_id != self.company_id:
return False
# Admins and Supervisors can log time to any project in their company
if user.role in [Role.ADMIN, Role.SUPERVISOR]:
return True
# If project is team-specific, only team members can log time
if self.team_id:
return user.team_id == self.team_id
# If no team restriction, any user in the company can log time
return True
class ProjectCategory(db.Model):
"""Project category for organization"""
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False)
description = db.Column(db.String(255))
color = db.Column(db.String(7), default='#3B82F6') # Hex color for UI
# Company association
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationships
projects = db.relationship('Project', back_populates='category')
company = db.relationship('Company', backref='project_categories')
# Unique constraint
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_category_name_per_company'),)
def __repr__(self):
return f'<ProjectCategory {self.name}>'

117
models/sprint.py Normal file
View File

@@ -0,0 +1,117 @@
"""
Sprint model for agile project management
"""
from datetime import datetime, date
from . import db
from .enums import SprintStatus, TaskStatus, Role
class Sprint(db.Model):
"""Sprint model for agile project management"""
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
# Sprint status
status = db.Column(db.Enum(SprintStatus), nullable=False, default=SprintStatus.PLANNING)
# Company association - sprints are company-scoped
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Optional project association - can be project-specific or company-wide
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True)
# Sprint timeline
start_date = db.Column(db.Date, nullable=False)
end_date = db.Column(db.Date, nullable=False)
# Sprint goals and metrics
goal = db.Column(db.Text, nullable=True) # Sprint goal description
capacity_hours = db.Column(db.Integer, nullable=True) # Planned capacity in hours
# 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='sprints')
project = db.relationship('Project', backref='sprints')
created_by = db.relationship('User', foreign_keys=[created_by_id])
tasks = db.relationship('Task', backref='sprint', lazy=True)
def __repr__(self):
return f'<Sprint {self.name}>'
@property
def is_current(self):
"""Check if this sprint is currently active"""
today = date.today()
return (self.status == SprintStatus.ACTIVE and
self.start_date <= today <= self.end_date)
@property
def duration_days(self):
"""Get sprint duration in days"""
return (self.end_date - self.start_date).days + 1
@property
def days_remaining(self):
"""Get remaining days in sprint"""
today = date.today()
if self.end_date < today:
return 0
elif self.start_date > today:
return self.duration_days
else:
return (self.end_date - today).days + 1
@property
def progress_percentage(self):
"""Calculate sprint progress percentage based on dates"""
today = date.today()
if today < self.start_date:
return 0
elif today > self.end_date:
return 100
else:
total_days = self.duration_days
elapsed_days = (today - self.start_date).days + 1
return min(100, int((elapsed_days / total_days) * 100))
def get_task_summary(self):
"""Get summary of tasks in this sprint"""
total_tasks = len(self.tasks)
completed_tasks = len([t for t in self.tasks if t.status == TaskStatus.DONE])
in_progress_tasks = len([t for t in self.tasks if t.status == TaskStatus.IN_PROGRESS])
return {
'total': total_tasks,
'completed': completed_tasks,
'in_progress': in_progress_tasks,
'not_started': total_tasks - completed_tasks - in_progress_tasks,
'completion_percentage': int((completed_tasks / total_tasks) * 100) if total_tasks > 0 else 0
}
def can_user_access(self, user):
"""Check if user can access this sprint"""
# Must be in same company
if self.company_id != user.company_id:
return False
# If sprint is project-specific, check project access
if self.project_id:
return self.project.is_user_allowed(user)
# Company-wide sprints can be accessed by all company users
return True
def can_user_modify(self, user):
"""Check if user can modify this sprint"""
if not self.can_user_access(user):
return False
# Only admins and supervisors can modify sprints
return user.role in [Role.ADMIN, Role.SUPERVISOR]

132
models/system.py Normal file
View File

@@ -0,0 +1,132 @@
"""
System-related models
"""
from datetime import datetime, timedelta
from . import db
class SystemSettings(db.Model):
"""Key-value store for system-wide settings"""
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(50), unique=True, nullable=False)
value = db.Column(db.String(255), nullable=False)
description = db.Column(db.String(255))
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f'<SystemSettings {self.key}={self.value}>'
class BrandingSettings(db.Model):
"""Branding and customization settings"""
id = db.Column(db.Integer, primary_key=True)
app_name = db.Column(db.String(100), nullable=False, default='Time Tracker')
logo_filename = db.Column(db.String(255), nullable=True) # Filename of uploaded logo
logo_alt_text = db.Column(db.String(255), nullable=True, default='Logo')
favicon_filename = db.Column(db.String(255), nullable=True) # Filename of uploaded favicon
primary_color = db.Column(db.String(7), nullable=True, default='#007bff') # Hex color
# Imprint/Legal page settings
imprint_enabled = db.Column(db.Boolean, default=False) # Enable/disable imprint page
imprint_title = db.Column(db.String(200), nullable=True, default='Imprint') # Page title
imprint_content = db.Column(db.Text, nullable=True) # HTML content for imprint page
# Meta fields
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
updated_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
# Relationships
updated_by = db.relationship('User', foreign_keys=[updated_by_id])
def __repr__(self):
return f'<BrandingSettings {self.app_name}>'
@staticmethod
def get_settings():
"""Get current branding settings or create defaults"""
settings = BrandingSettings.query.first()
if not settings:
settings = BrandingSettings(
app_name='Time Tracker',
logo_alt_text='Application Logo'
)
db.session.add(settings)
db.session.commit()
return settings
@staticmethod
def get_current():
"""Alias for get_settings() for backward compatibility"""
return BrandingSettings.get_settings()
class SystemEvent(db.Model):
"""System event logging for audit and monitoring"""
id = db.Column(db.Integer, primary_key=True)
event_type = db.Column(db.String(50), nullable=False) # e.g., 'login', 'logout', 'user_created', 'system_error'
event_category = db.Column(db.String(30), nullable=False) # e.g., 'auth', 'user_management', 'system', 'error'
description = db.Column(db.Text, nullable=False)
severity = db.Column(db.String(20), default='info') # 'info', 'warning', 'error', 'critical'
timestamp = db.Column(db.DateTime, default=datetime.now, nullable=False)
# Optional associations
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=True)
# Additional metadata (JSON string)
event_metadata = db.Column(db.Text, nullable=True) # Store additional event data as JSON
# IP address and user agent for security tracking
ip_address = db.Column(db.String(45), nullable=True) # IPv6 compatible
user_agent = db.Column(db.Text, nullable=True)
# Relationships
user = db.relationship('User', backref='system_events')
company = db.relationship('Company', backref='system_events')
def __repr__(self):
return f'<SystemEvent {self.event_type}: {self.description[:50]}>'
@staticmethod
def log_event(event_type, description, event_category='system', severity='info',
user_id=None, company_id=None, event_metadata=None, ip_address=None, user_agent=None):
"""Helper method to log system events"""
event = SystemEvent(
event_type=event_type,
event_category=event_category,
description=description,
severity=severity,
user_id=user_id,
company_id=company_id,
event_metadata=event_metadata,
ip_address=ip_address,
user_agent=user_agent
)
db.session.add(event)
try:
db.session.commit()
except Exception as e:
db.session.rollback()
# Log to application logger if DB logging fails
import logging
logging.error(f"Failed to log system event: {e}")
@staticmethod
def get_recent_events(days=7, limit=100):
"""Get recent system events from the last N days"""
since = datetime.now() - timedelta(days=days)
return SystemEvent.query.filter(
SystemEvent.timestamp >= since
).order_by(SystemEvent.timestamp.desc()).limit(limit).all()
@staticmethod
def cleanup_old_events(days=90):
"""Delete system events older than specified days"""
cutoff_date = datetime.now() - timedelta(days=days)
deleted_count = SystemEvent.query.filter(
SystemEvent.timestamp < cutoff_date
).delete()
db.session.commit()
return deleted_count

245
models/task.py Normal file
View File

@@ -0,0 +1,245 @@
"""
Task-related models
"""
from datetime import datetime
from . import db
from .enums import TaskStatus, TaskPriority, CommentVisibility, Role
class Task(db.Model):
"""Task model for project management"""
id = db.Column(db.Integer, primary_key=True)
task_number = db.Column(db.String(20), nullable=False, unique=True) # e.g., "TSK-001", "TSK-002"
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.TODO)
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)
# Sprint association (optional)
sprint_id = db.Column(db.Integer, db.ForeignKey('sprint.id'), nullable=True)
# 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)
archived_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.DONE else 0
completed_subtasks = sum(1 for subtask in self.subtasks if subtask.status == TaskStatus.DONE)
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)
@classmethod
def generate_task_number(cls, company_id):
"""Generate next task number for the company"""
# Get the highest task number for this company
last_task = cls.query.join(Project).filter(
Project.company_id == company_id,
cls.task_number.like('TSK-%')
).order_by(cls.task_number.desc()).first()
if last_task and last_task.task_number:
try:
# Extract number from TSK-XXX format
last_num = int(last_task.task_number.split('-')[1])
return f"TSK-{last_num + 1:03d}"
except (IndexError, ValueError):
pass
return "TSK-001"
@property
def blocked_by_tasks(self):
"""Get tasks that are blocking this task"""
return [dep.blocking_task for dep in self.blocked_by_dependencies]
@property
def blocking_tasks(self):
"""Get tasks that this task is blocking"""
return [dep.blocked_task for dep in self.blocking_dependencies]
class TaskDependency(db.Model):
"""Track dependencies between tasks"""
id = db.Column(db.Integer, primary_key=True)
# The task that is blocked (cannot start until blocking task is done)
blocked_task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
# The task that is blocking (must be completed before blocked task can start)
blocking_task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
# Dependency type (for future extension)
dependency_type = db.Column(db.String(50), default='blocks', nullable=False) # 'blocks', 'subtask', etc.
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
blocked_task = db.relationship('Task', foreign_keys=[blocked_task_id],
backref=db.backref('blocked_by_dependencies', cascade='all, delete-orphan'))
blocking_task = db.relationship('Task', foreign_keys=[blocking_task_id],
backref=db.backref('blocking_dependencies', cascade='all, delete-orphan'))
created_by = db.relationship('User', foreign_keys=[created_by_id])
# Ensure a task doesn't block itself and prevent duplicate dependencies
__table_args__ = (
db.CheckConstraint('blocked_task_id != blocking_task_id', name='no_self_blocking'),
db.UniqueConstraint('blocked_task_id', 'blocking_task_id', name='unique_dependency'),
)
def __repr__(self):
return f'<TaskDependency {self.blocking_task_id} blocks {self.blocked_task_id}>'
class SubTask(db.Model):
"""Subtask model for breaking down tasks"""
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.TODO)
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)
class Comment(db.Model):
"""Comment model for task discussions"""
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.Text, nullable=False)
# Task association
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
# Parent comment for thread support
parent_comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'), nullable=True)
# Visibility setting
visibility = db.Column(db.Enum(CommentVisibility), default=CommentVisibility.COMPANY)
# Edit tracking
is_edited = db.Column(db.Boolean, default=False)
edited_at = db.Column(db.DateTime, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
task = db.relationship('Task', backref=db.backref('comments', lazy='dynamic', cascade='all, delete-orphan'))
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='comments')
replies = db.relationship('Comment', backref=db.backref('parent_comment', remote_side=[id]))
def __repr__(self):
return f'<Comment {self.id} on Task {self.task_id}>'
def can_user_view(self, user):
"""Check if a user can view this comment based on visibility settings"""
# First check if user can access the task
if not self.task.can_user_access(user):
return False
# Check visibility rules
if self.visibility == CommentVisibility.PRIVATE:
return user.id == self.created_by_id
elif self.visibility == CommentVisibility.TEAM:
# User must be in the same team as the task's project
if self.task.project.team_id:
return user.team_id == self.task.project.team_id
return True # If no team restriction, all company users can see
else: # CommentVisibility.COMPANY
return True # All company users can see
def can_user_edit(self, user):
"""Check if a user can edit this comment"""
# Only the creator can edit their own comments
return user.id == self.created_by_id
def can_user_delete(self, user):
"""Check if a user can delete this comment"""
# Creator can delete their own comments
if user.id == self.created_by_id:
return True
# Admins can delete any comment in their company
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
return True
# Team leaders can delete comments on their team's tasks
if user.role == Role.TEAM_LEADER and self.task.project.team_id == user.team_id:
return True
return False

26
models/team.py Normal file
View File

@@ -0,0 +1,26 @@
"""
Team model
"""
from datetime import datetime
from . import db
class Team(db.Model):
"""Team model for organizing users"""
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.String(255))
created_at = db.Column(db.DateTime, default=datetime.now)
# Company association for multi-tenancy
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Relationship with users (one team has many users)
users = db.relationship('User', backref='team', lazy=True)
# Unique constraint per company
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_team_name_per_company'),)
def __repr__(self):
return f'<Team {self.name}>'

32
models/time_entry.py Normal file
View File

@@ -0,0 +1,32 @@
"""
Time entry model for tracking work hours
"""
from datetime import datetime
from . import db
class TimeEntry(db.Model):
"""Time entry model for tracking work hours"""
id = db.Column(db.Integer, primary_key=True)
arrival_time = db.Column(db.DateTime, nullable=False)
departure_time = db.Column(db.DateTime, nullable=True)
duration = db.Column(db.Integer, nullable=True) # Duration in seconds
is_paused = db.Column(db.Boolean, default=False)
pause_start_time = db.Column(db.DateTime, nullable=True)
total_break_duration = db.Column(db.Integer, default=0) # Total break duration in seconds
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
# 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)
def __repr__(self):
project_info = f" (Project: {self.project.code})" if self.project else ""
return f'<TimeEntry {self.id}: {self.arrival_time} - {self.departure_time}{project_info}>'

203
models/user.py Normal file
View File

@@ -0,0 +1,203 @@
"""
User-related models
"""
from datetime import datetime, timedelta
from werkzeug.security import generate_password_hash, check_password_hash
import secrets
from . import db
from .enums import Role, AccountType, WidgetType, WidgetSize
class User(db.Model):
"""User model with multi-tenancy and role-based access"""
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), nullable=False)
email = db.Column(db.String(120), nullable=True)
password_hash = db.Column(db.String(128))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Company association for multi-tenancy
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Email verification fields
is_verified = db.Column(db.Boolean, default=False)
verification_token = db.Column(db.String(100), unique=True, nullable=True)
token_expiry = db.Column(db.DateTime, nullable=True)
# New field for blocking users
is_blocked = db.Column(db.Boolean, default=False)
# New fields for role and team
role = db.Column(db.Enum(Role, values_callable=lambda obj: [e.value for e in obj]), 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, values_callable=lambda obj: [e.value for e in obj]), 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'),
db.UniqueConstraint('company_id', 'email', name='uq_user_email_per_company'),
)
# Two-Factor Authentication fields
two_factor_enabled = db.Column(db.Boolean, default=False)
two_factor_secret = db.Column(db.String(32), nullable=True) # Base32 encoded secret
# Avatar field
avatar_url = db.Column(db.String(255), nullable=True) # URL to user's avatar image
# Relationships
time_entries = db.relationship('TimeEntry', backref='user', lazy=True)
work_config = db.relationship('WorkConfig', backref='user', lazy=True, uselist=False)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def generate_verification_token(self):
"""Generate a verification token that expires in 24 hours"""
self.verification_token = secrets.token_urlsafe(32)
self.token_expiry = datetime.utcnow() + timedelta(hours=24)
return self.verification_token
def verify_token(self, token):
"""Verify the token and mark user as verified if valid"""
if token == self.verification_token and self.token_expiry > datetime.utcnow():
self.is_verified = True
self.verification_token = None
self.token_expiry = None
return True
return False
def generate_2fa_secret(self):
"""Generate a new 2FA secret"""
import pyotp
self.two_factor_secret = pyotp.random_base32()
return self.two_factor_secret
def get_2fa_uri(self, issuer_name=None):
"""Get the provisioning URI for QR code generation"""
if not self.two_factor_secret:
return None
import pyotp
totp = pyotp.TOTP(self.two_factor_secret)
if issuer_name is None:
issuer_name = "Time Tracker" # Default fallback
return totp.provisioning_uri(
name=self.email,
issuer_name=issuer_name
)
def verify_2fa_token(self, token, allow_setup=False):
"""Verify a 2FA token"""
if not self.two_factor_secret:
return False
# During setup, allow verification even if 2FA isn't enabled yet
if not allow_setup and not self.two_factor_enabled:
return False
import pyotp
totp = pyotp.TOTP(self.two_factor_secret)
return totp.verify(token, valid_window=1) # Allow 1 window tolerance
def get_avatar_url(self, size=40):
"""Get user's avatar URL or generate a default one"""
if self.avatar_url:
return self.avatar_url
# Generate a default avatar using DiceBear Avatars (similar to GitHub's identicons)
# Using initials style for a clean, professional look
import hashlib
# Create a hash from username for consistent colors
hash_input = f"{self.username}_{self.id}".encode('utf-8')
hash_hex = hashlib.md5(hash_input).hexdigest()
# Use DiceBear API for avatar generation
# For initials style, we need to provide the actual initials
initials = self.get_initials()
# Generate avatar URL with initials
# Using a color based on the hash for consistency
bg_colors = ['0ea5e9', '8b5cf6', 'ec4899', 'f59e0b', '10b981', 'ef4444', '3b82f6', '6366f1']
color_index = int(hash_hex[:2], 16) % len(bg_colors)
bg_color = bg_colors[color_index]
avatar_url = f"https://api.dicebear.com/7.x/initials/svg?seed={initials}&size={size}&backgroundColor={bg_color}&fontSize=50"
return avatar_url
def get_initials(self):
"""Get user initials for avatar display"""
parts = self.username.split()
if len(parts) >= 2:
return f"{parts[0][0]}{parts[-1][0]}".upper()
elif self.username:
return self.username[:2].upper()
return "??"
def __repr__(self):
return f'<User {self.username}>'
class UserPreferences(db.Model):
"""User preferences and settings"""
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), unique=True, nullable=False)
# UI preferences
theme = db.Column(db.String(20), default='light')
language = db.Column(db.String(10), default='en')
timezone = db.Column(db.String(50), default='UTC')
date_format = db.Column(db.String(20), default='YYYY-MM-DD')
time_format = db.Column(db.String(10), default='24h')
# Notification preferences
email_notifications = db.Column(db.Boolean, default=True)
email_daily_summary = db.Column(db.Boolean, default=False)
email_weekly_summary = db.Column(db.Boolean, default=True)
# Time tracking preferences
default_project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True)
timer_reminder_enabled = db.Column(db.Boolean, default=True)
timer_reminder_interval = db.Column(db.Integer, default=60) # Minutes
# Dashboard preferences
dashboard_layout = db.Column(db.JSON, nullable=True) # Store custom dashboard layout
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
user = db.relationship('User', backref=db.backref('preferences', uselist=False))
default_project = db.relationship('Project')
class UserDashboard(db.Model):
"""User's dashboard configuration"""
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
name = db.Column(db.String(100), default='My Dashboard')
is_default = db.Column(db.Boolean, default=True)
layout_config = db.Column(db.Text) # JSON string for grid layout configuration
# Dashboard settings
grid_columns = db.Column(db.Integer, default=6) # Number of grid columns
theme = db.Column(db.String(20), default='light') # light, dark, auto
auto_refresh = db.Column(db.Integer, default=300) # Auto-refresh interval in seconds
# Additional configuration (from new model)
layout = db.Column(db.JSON, nullable=True) # Grid layout configuration (alternative format)
is_locked = db.Column(db.Boolean, default=False) # Prevent accidental changes
# Timestamps
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
user = db.relationship('User', backref=db.backref('dashboards', lazy='dynamic'))
widgets = db.relationship('DashboardWidget', backref='dashboard', lazy=True, cascade='all, delete')

31
models/work_config.py Normal file
View File

@@ -0,0 +1,31 @@
"""
Work configuration model
"""
from datetime import datetime
from . import db
class WorkConfig(db.Model):
"""User-specific work configuration settings"""
id = db.Column(db.Integer, primary_key=True)
work_hours_per_day = db.Column(db.Float, default=8.0) # Default 8 hours
mandatory_break_minutes = db.Column(db.Integer, default=30) # Default 30 minutes
break_threshold_hours = db.Column(db.Float, default=6.0) # Work hours that trigger mandatory break
additional_break_minutes = db.Column(db.Integer, default=15) # Default 15 minutes for additional break
additional_break_threshold_hours = db.Column(db.Float, default=9.0) # Work hours that trigger additional break
# Time rounding settings
time_rounding_minutes = db.Column(db.Integer, default=0) # 0 = no rounding, 15 = 15 min, 30 = 30 min
round_to_nearest = db.Column(db.Boolean, default=True) # True = round to nearest, False = round up
# Date/time format settings
time_format_24h = db.Column(db.Boolean, default=True) # True = 24h, False = 12h (AM/PM)
date_format = db.Column(db.String(20), default='ISO') # ISO, US, EU, etc.
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
def __repr__(self):
return f'<WorkConfig {self.id}: {self.work_hours_per_day}h/day, {self.mandatory_break_minutes}min break>'

189
routes/announcements.py Normal file
View File

@@ -0,0 +1,189 @@
"""
Announcement management routes
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, g
from models import db, Announcement, Company, User, Role
from routes.auth import system_admin_required
from datetime import datetime
import json
import logging
logger = logging.getLogger(__name__)
announcements_bp = Blueprint('announcements', __name__, url_prefix='/system-admin/announcements')
@announcements_bp.route('')
@system_admin_required
def index():
"""System Admin: Manage announcements"""
page = request.args.get('page', 1, type=int)
per_page = 20
announcements = Announcement.query.order_by(Announcement.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False)
return render_template('system_admin_announcements.html',
title='System Admin - Announcements',
announcements=announcements)
@announcements_bp.route('/new', methods=['GET', 'POST'])
@system_admin_required
def create():
"""System Admin: Create new announcement"""
if request.method == 'POST':
title = request.form.get('title')
content = request.form.get('content')
announcement_type = request.form.get('announcement_type', 'info')
is_urgent = request.form.get('is_urgent') == 'on'
is_active = request.form.get('is_active') == 'on'
# Handle date fields
start_date = request.form.get('start_date')
end_date = request.form.get('end_date')
start_datetime = None
end_datetime = None
if start_date:
try:
start_datetime = datetime.strptime(start_date, '%Y-%m-%dT%H:%M')
except ValueError:
pass
if end_date:
try:
end_datetime = datetime.strptime(end_date, '%Y-%m-%dT%H:%M')
except ValueError:
pass
# Handle targeting
target_all_users = request.form.get('target_all_users') == 'on'
target_roles = None
target_companies = None
if not target_all_users:
selected_roles = request.form.getlist('target_roles')
selected_companies = request.form.getlist('target_companies')
if selected_roles:
target_roles = json.dumps(selected_roles)
if selected_companies:
target_companies = json.dumps([int(c) for c in selected_companies])
announcement = Announcement(
title=title,
content=content,
announcement_type=announcement_type,
is_urgent=is_urgent,
is_active=is_active,
start_date=start_datetime,
end_date=end_datetime,
target_all_users=target_all_users,
target_roles=target_roles,
target_companies=target_companies,
created_by_id=g.user.id
)
db.session.add(announcement)
db.session.commit()
flash('Announcement created successfully.', 'success')
return redirect(url_for('announcements.index'))
# Get roles and companies for targeting options
roles = [role.value for role in Role]
companies = Company.query.order_by(Company.name).all()
return render_template('system_admin_announcement_form.html',
title='Create Announcement',
announcement=None,
roles=roles,
companies=companies)
@announcements_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@system_admin_required
def edit(id):
"""System Admin: Edit announcement"""
announcement = Announcement.query.get_or_404(id)
if request.method == 'POST':
announcement.title = request.form.get('title')
announcement.content = request.form.get('content')
announcement.announcement_type = request.form.get('announcement_type', 'info')
announcement.is_urgent = request.form.get('is_urgent') == 'on'
announcement.is_active = request.form.get('is_active') == 'on'
# Handle date fields
start_date = request.form.get('start_date')
end_date = request.form.get('end_date')
if start_date:
try:
announcement.start_date = datetime.strptime(start_date, '%Y-%m-%dT%H:%M')
except ValueError:
announcement.start_date = None
else:
announcement.start_date = None
if end_date:
try:
announcement.end_date = datetime.strptime(end_date, '%Y-%m-%dT%H:%M')
except ValueError:
announcement.end_date = None
else:
announcement.end_date = None
# Handle targeting
announcement.target_all_users = request.form.get('target_all_users') == 'on'
if not announcement.target_all_users:
selected_roles = request.form.getlist('target_roles')
selected_companies = request.form.getlist('target_companies')
if selected_roles:
announcement.target_roles = json.dumps(selected_roles)
else:
announcement.target_roles = None
if selected_companies:
announcement.target_companies = json.dumps([int(c) for c in selected_companies])
else:
announcement.target_companies = None
else:
announcement.target_roles = None
announcement.target_companies = None
announcement.updated_at = datetime.now()
db.session.commit()
flash('Announcement updated successfully.', 'success')
return redirect(url_for('announcements.index'))
# Get roles and companies for targeting options
roles = [role.value for role in Role]
companies = Company.query.order_by(Company.name).all()
return render_template('system_admin_announcement_form.html',
title='Edit Announcement',
announcement=announcement,
roles=roles,
companies=companies)
@announcements_bp.route('/<int:id>/delete', methods=['POST'])
@system_admin_required
def delete(id):
"""System Admin: Delete announcement"""
announcement = Announcement.query.get_or_404(id)
db.session.delete(announcement)
db.session.commit()
flash('Announcement deleted successfully.', 'success')
return redirect(url_for('announcements.index'))

View File

@@ -1,14 +1,13 @@
# Standard library imports
"""
Authentication decorators for route protection
"""
from functools import wraps
# Third-party imports
from flask import flash, g, redirect, request, url_for
# Local application imports
from models import Company, Role, User
from flask import g, redirect, url_for, flash, request
from models import Role, Company
def login_required(f):
"""Decorator to require login for routes"""
@wraps(f)
def decorated_function(*args, **kwargs):
if g.user is None:
@@ -17,10 +16,41 @@ def login_required(f):
return decorated_function
def role_required(min_role):
"""Decorator to require a minimum role for routes"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if g.user is None:
return redirect(url_for('login', next=request.url))
# Admin and System Admin always have access
if g.user.role == Role.ADMIN or g.user.role == Role.SYSTEM_ADMIN:
return f(*args, **kwargs)
# Define role hierarchy
role_hierarchy = {
Role.TEAM_MEMBER: 1,
Role.TEAM_LEADER: 2,
Role.SUPERVISOR: 3,
Role.ADMIN: 4,
Role.SYSTEM_ADMIN: 5
}
user_role_value = role_hierarchy.get(g.user.role, 0)
min_role_value = role_hierarchy.get(min_role, 0)
if user_role_value < min_role_value:
flash('You do not have sufficient permissions to access this page.', 'error')
return redirect(url_for('home'))
return f(*args, **kwargs)
return decorated_function
return decorator
def company_required(f):
"""
Decorator to ensure user has a valid company association and set company context.
"""
"""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:
@@ -37,40 +67,34 @@ def company_required(f):
# 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 account is inactive.', 'error')
return redirect(url_for('home'))
flash('Your company is not active. Please contact support.', 'error')
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
def role_required(*allowed_roles):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if g.user.role not in allowed_roles:
flash('You do not have permission to access this page.', 'error')
return redirect(url_for('dashboard'))
return f(*args, **kwargs)
return decorated_function
return decorator
def admin_required(f):
"""Decorator to require admin role for routes"""
@wraps(f)
def decorated_function(*args, **kwargs):
if g.user is None:
return redirect(url_for('login'))
if g.user.role not in [Role.ADMIN, Role.SYSTEM_ADMIN]:
flash('Admin access required.', 'error')
return redirect(url_for('dashboard'))
flash('You must be an administrator to access this page.', 'error')
return redirect(url_for('home'))
return f(*args, **kwargs)
return decorated_function
def system_admin_required(f):
"""Decorator to require system admin role for routes"""
@wraps(f)
def decorated_function(*args, **kwargs):
if g.user is None:
return redirect(url_for('login'))
if g.user.role != Role.SYSTEM_ADMIN:
flash('System admin access required.', 'error')
return redirect(url_for('dashboard'))
flash('You must be a system administrator to access this page.', 'error')
return redirect(url_for('home'))
return f(*args, **kwargs)
return decorated_function

360
routes/company.py Normal file
View File

@@ -0,0 +1,360 @@
"""
Company management routes
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, g, session
from models import db, Company, User, Role, Team, Project, SystemSettings, CompanyWorkConfig, WorkRegion
from routes.auth import admin_required, company_required, login_required
import logging
import re
logger = logging.getLogger(__name__)
companies_bp = Blueprint('companies', __name__, url_prefix='/admin/company')
@companies_bp.route('', methods=['GET', 'POST'])
@admin_required
@company_required
def admin_company():
"""View and manage company settings"""
company = g.company
# Handle form submissions
if request.method == 'POST':
action = request.form.get('action')
if action == 'update_company_details':
# Handle company details update
name = request.form.get('name')
description = request.form.get('description', '')
max_users = request.form.get('max_users')
is_active = 'is_active' in request.form
# Validate input
error = None
if not name:
error = 'Company name is required'
elif name != company.name and Company.query.filter_by(name=name).first():
error = 'Company name already exists'
if max_users:
try:
max_users = int(max_users)
if max_users < 1:
error = 'Maximum users must be at least 1'
except ValueError:
error = 'Maximum users must be a valid number'
else:
max_users = None
if error is None:
company.name = name
company.description = description
company.max_users = max_users
company.is_active = is_active
db.session.commit()
flash('Company details updated successfully!', 'success')
else:
flash(error, 'error')
return redirect(url_for('companies.admin_company'))
elif action == 'update_system_settings':
# Update registration setting
registration_enabled = 'registration_enabled' in request.form
reg_setting = SystemSettings.query.filter_by(key='registration_enabled').first()
if reg_setting:
reg_setting.value = 'true' if registration_enabled else 'false'
# Update email verification setting
email_verification_required = 'email_verification_required' in request.form
email_setting = SystemSettings.query.filter_by(key='email_verification_required').first()
if email_setting:
email_setting.value = 'true' if email_verification_required else 'false'
db.session.commit()
flash('System settings updated successfully!', 'success')
return redirect(url_for('companies.admin_company'))
elif action == 'update_work_policies':
# Get or create company work config
work_config = CompanyWorkConfig.query.filter_by(company_id=g.user.company_id).first()
if not work_config:
# Create default config for the company
preset = CompanyWorkConfig.get_regional_preset(WorkRegion.GERMANY)
work_config = CompanyWorkConfig(
company_id=g.user.company_id,
standard_hours_per_day=preset['standard_hours_per_day'],
standard_hours_per_week=preset['standard_hours_per_week'],
work_region=WorkRegion.GERMANY,
overtime_enabled=preset['overtime_enabled'],
overtime_rate=preset['overtime_rate'],
double_time_enabled=preset['double_time_enabled'],
double_time_threshold=preset['double_time_threshold'],
double_time_rate=preset['double_time_rate'],
require_breaks=preset['require_breaks'],
break_duration_minutes=preset['break_duration_minutes'],
break_after_hours=preset['break_after_hours'],
weekly_overtime_threshold=preset['weekly_overtime_threshold'],
weekly_overtime_rate=preset['weekly_overtime_rate']
)
db.session.add(work_config)
db.session.flush()
try:
# Handle regional preset selection
if request.form.get('apply_preset'):
region_code = request.form.get('region_preset')
if region_code:
region = WorkRegion(region_code)
preset = CompanyWorkConfig.get_regional_preset(region)
work_config.standard_hours_per_day = preset['standard_hours_per_day']
work_config.standard_hours_per_week = preset['standard_hours_per_week']
work_config.work_region = region
work_config.overtime_enabled = preset['overtime_enabled']
work_config.overtime_rate = preset['overtime_rate']
work_config.double_time_enabled = preset['double_time_enabled']
work_config.double_time_threshold = preset['double_time_threshold']
work_config.double_time_rate = preset['double_time_rate']
work_config.require_breaks = preset['require_breaks']
work_config.break_duration_minutes = preset['break_duration_minutes']
work_config.break_after_hours = preset['break_after_hours']
work_config.weekly_overtime_threshold = preset['weekly_overtime_threshold']
work_config.weekly_overtime_rate = preset['weekly_overtime_rate']
db.session.commit()
flash(f'Applied {preset["region_name"]} work policy preset', 'success')
else:
# Handle manual configuration update
work_config.standard_hours_per_day = float(request.form.get('standard_hours_per_day', 8.0))
work_config.standard_hours_per_week = float(request.form.get('standard_hours_per_week', 40.0))
work_config.overtime_enabled = request.form.get('overtime_enabled') == 'on'
work_config.overtime_rate = float(request.form.get('overtime_rate', 1.5))
work_config.double_time_enabled = request.form.get('double_time_enabled') == 'on'
work_config.double_time_threshold = float(request.form.get('double_time_threshold', 12.0))
work_config.double_time_rate = float(request.form.get('double_time_rate', 2.0))
work_config.require_breaks = request.form.get('require_breaks') == 'on'
work_config.break_duration_minutes = int(request.form.get('break_duration_minutes', 30))
work_config.break_after_hours = float(request.form.get('break_after_hours', 6.0))
work_config.weekly_overtime_threshold = float(request.form.get('weekly_overtime_threshold', 40.0))
work_config.weekly_overtime_rate = float(request.form.get('weekly_overtime_rate', 1.5))
work_config.work_region = WorkRegion.OTHER
# region_name removed - using work_region enum value instead
db.session.commit()
flash('Work policies updated successfully!', 'success')
except ValueError:
flash('Please enter valid numbers for all fields', 'error')
return redirect(url_for('companies.admin_company'))
# Get company statistics
stats = {
'total_users': User.query.filter_by(company_id=company.id).count(),
'total_teams': Team.query.filter_by(company_id=company.id).count(),
'total_projects': Project.query.filter_by(company_id=company.id).count(),
'active_projects': Project.query.filter_by(company_id=company.id, is_active=True).count(),
}
# Get current system settings
settings = {}
for setting in SystemSettings.query.all():
if setting.key == 'registration_enabled':
settings['registration_enabled'] = setting.value == 'true'
elif setting.key == 'email_verification_required':
settings['email_verification_required'] = setting.value == 'true'
# Get or create company work config
work_config = CompanyWorkConfig.query.filter_by(company_id=g.user.company_id).first()
if not work_config:
# Create default config for the company
preset = CompanyWorkConfig.get_regional_preset(WorkRegion.GERMANY)
work_config = CompanyWorkConfig(
company_id=g.user.company_id,
standard_hours_per_day=preset['standard_hours_per_day'],
standard_hours_per_week=preset['standard_hours_per_week'],
work_region=WorkRegion.GERMANY,
overtime_enabled=preset['overtime_enabled'],
overtime_rate=preset['overtime_rate'],
double_time_enabled=preset['double_time_enabled'],
double_time_threshold=preset['double_time_threshold'],
double_time_rate=preset['double_time_rate'],
require_breaks=preset['require_breaks'],
break_duration_minutes=preset['break_duration_minutes'],
break_after_hours=preset['break_after_hours'],
weekly_overtime_threshold=preset['weekly_overtime_threshold'],
weekly_overtime_rate=preset['weekly_overtime_rate']
)
db.session.add(work_config)
db.session.commit()
# Get available regional presets
regional_presets = []
for region in WorkRegion:
preset = CompanyWorkConfig.get_regional_preset(region)
regional_presets.append({
'code': region.value,
'name': preset['region_name'],
'description': f"{preset['standard_hours_per_day']}h/day, {preset['break_duration_minutes']}min break after {preset['break_after_hours']}h"
})
return render_template('admin_company.html',
title='Company Management',
company=company,
stats=stats,
settings=settings,
work_config=work_config,
regional_presets=regional_presets,
WorkRegion=WorkRegion)
@companies_bp.route('/users')
@admin_required
@company_required
def company_users():
"""List all users in the company with detailed information"""
users = User.query.filter_by(company_id=g.company.id).order_by(User.created_at.desc()).all()
# Calculate user statistics
user_stats = {
'total': len(users),
'verified': len([u for u in users if u.is_verified]),
'unverified': len([u for u in users if not u.is_verified]),
'blocked': len([u for u in users if u.is_blocked]),
'active': len([u for u in users if not u.is_blocked and u.is_verified]),
'admins': len([u for u in users if u.role == Role.ADMIN]),
'supervisors': len([u for u in users if u.role == Role.SUPERVISOR]),
'team_leaders': len([u for u in users if u.role == Role.TEAM_LEADER]),
'team_members': len([u for u in users if u.role == Role.TEAM_MEMBER]),
}
return render_template('company_users.html',
title='Company Users',
company=g.company,
users=users,
stats=user_stats)
# Setup company route (separate from company blueprint due to different URL)
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_system_admin = g.user and g.user.role == Role.SYSTEM_ADMIN
is_authorized = is_initial_setup or is_system_admin
# Check authorization for non-initial setups
if not is_initial_setup and not is_system_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
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('companies.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_system_admin)

102
routes/company_api.py Normal file
View File

@@ -0,0 +1,102 @@
"""
Company API endpoints
"""
from flask import Blueprint, jsonify, g
from models import db, Company, User, Role, Team, Project, TimeEntry
from routes.auth import system_admin_required
from datetime import datetime, timedelta
company_api_bp = Blueprint('company_api', __name__, url_prefix='/api')
@company_api_bp.route('/system-admin/companies/<int:company_id>/users')
@system_admin_required
def api_company_users(company_id):
"""API: Get users for a specific company (System Admin only)"""
company = Company.query.get_or_404(company_id)
users = User.query.filter_by(company_id=company.id).order_by(User.username).all()
return jsonify({
'company': {
'id': company.id,
'name': company.name,
'is_personal': company.is_personal
},
'users': [{
'id': user.id,
'username': user.username,
'email': user.email,
'role': user.role.value,
'is_blocked': user.is_blocked,
'is_verified': user.is_verified,
'created_at': user.created_at.isoformat(),
'team_id': user.team_id
} for user in users]
})
@company_api_bp.route('/system-admin/companies/<int:company_id>/stats')
@system_admin_required
def api_company_stats(company_id):
"""API: Get detailed statistics for a specific company"""
company = Company.query.get_or_404(company_id)
# User counts by role
role_counts = {}
for role in Role:
count = User.query.filter_by(company_id=company.id, role=role).count()
if count > 0:
role_counts[role.value] = count
# Team and project counts
team_count = Team.query.filter_by(company_id=company.id).count()
project_count = Project.query.filter_by(company_id=company.id).count()
active_projects = Project.query.filter_by(company_id=company.id, is_active=True).count()
# Time entries statistics
week_ago = datetime.now() - timedelta(days=7)
month_ago = datetime.now() - timedelta(days=30)
weekly_entries = TimeEntry.query.join(User).filter(
User.company_id == company.id,
TimeEntry.arrival_time >= week_ago
).count()
monthly_entries = TimeEntry.query.join(User).filter(
User.company_id == company.id,
TimeEntry.arrival_time >= month_ago
).count()
# Active sessions
active_sessions = TimeEntry.query.join(User).filter(
User.company_id == company.id,
TimeEntry.departure_time == None,
TimeEntry.is_paused == False
).count()
return jsonify({
'company': {
'id': company.id,
'name': company.name,
'is_personal': company.is_personal,
'is_active': company.is_active
},
'users': {
'total': User.query.filter_by(company_id=company.id).count(),
'verified': User.query.filter_by(company_id=company.id, is_verified=True).count(),
'blocked': User.query.filter_by(company_id=company.id, is_blocked=True).count(),
'roles': role_counts
},
'teams': team_count,
'projects': {
'total': project_count,
'active': active_projects,
'inactive': project_count - active_projects
},
'time_entries': {
'weekly': weekly_entries,
'monthly': monthly_entries,
'active_sessions': active_sessions
}
})

94
routes/export.py Normal file
View File

@@ -0,0 +1,94 @@
"""
Export routes for TimeTrack application.
Handles data export functionality for time entries and analytics.
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, g
from datetime import datetime, time, timedelta
from models import db, TimeEntry, Role, Project
from data_formatting import prepare_export_data
from data_export import export_to_csv, export_to_excel
from routes.auth import login_required, company_required
# Create blueprint
export_bp = Blueprint('export', __name__, url_prefix='/export')
@export_bp.route('/')
@login_required
@company_required
def export_page():
"""Display the export page."""
return render_template('export.html', title='Export Data')
def get_date_range(period, start_date_str=None, end_date_str=None):
"""Get start and end date based on period or custom date range."""
today = datetime.now().date()
if period:
if period == 'today':
return today, today
elif period == 'week':
start_date = today - timedelta(days=today.weekday())
return start_date, today
elif period == 'month':
start_date = today.replace(day=1)
return start_date, today
elif period == 'all':
earliest_entry = TimeEntry.query.order_by(TimeEntry.arrival_time).first()
start_date = earliest_entry.arrival_time.date() if earliest_entry else today
return start_date, today
else:
# Custom date range
try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
return start_date, end_date
except (ValueError, TypeError):
raise ValueError('Invalid date format')
@export_bp.route('/download')
@login_required
@company_required
def download_export():
"""Handle export download requests."""
export_format = request.args.get('format', 'csv')
period = request.args.get('period')
try:
start_date, end_date = get_date_range(
period,
request.args.get('start_date'),
request.args.get('end_date')
)
except ValueError:
flash('Invalid date format. Please use YYYY-MM-DD format.')
return redirect(url_for('export.export_page'))
# Query entries within the date range
start_datetime = datetime.combine(start_date, time.min)
end_datetime = datetime.combine(end_date, time.max)
entries = TimeEntry.query.filter(
TimeEntry.arrival_time >= start_datetime,
TimeEntry.arrival_time <= end_datetime
).order_by(TimeEntry.arrival_time).all()
if not entries:
flash('No entries found for the selected date range.')
return redirect(url_for('export.export_page'))
# Prepare data and filename
data = prepare_export_data(entries)
filename = f"timetrack_export_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}"
# Export based on format
if export_format == 'csv':
return export_to_csv(data, filename)
elif export_format == 'excel':
return export_to_excel(data, filename)
else:
flash('Invalid export format.')
return redirect(url_for('export.export_page'))

96
routes/export_api.py Normal file
View File

@@ -0,0 +1,96 @@
"""
Export API routes for TimeTrack application.
Handles API endpoints for data export functionality.
"""
from flask import Blueprint, request, redirect, url_for, flash, g
from datetime import datetime
from models import Role
from routes.auth import login_required, role_required, company_required
from data_export import export_analytics_csv, export_analytics_excel
import logging
logger = logging.getLogger(__name__)
# Create blueprint
export_api_bp = Blueprint('export_api', __name__, url_prefix='/api')
def get_filtered_analytics_data(user, mode, start_date=None, end_date=None, project_filter=None):
"""Get filtered time entry data for analytics"""
from models import TimeEntry, User
from sqlalchemy import func
# Base query
query = TimeEntry.query
# Apply user/team filter
if mode == 'personal':
query = query.filter(TimeEntry.user_id == user.id)
elif mode == 'team' and user.team_id:
team_user_ids = [u.id for u in User.query.filter_by(team_id=user.team_id).all()]
query = query.filter(TimeEntry.user_id.in_(team_user_ids))
# Apply date filters
if start_date:
query = query.filter(func.date(TimeEntry.arrival_time) >= start_date)
if end_date:
query = query.filter(func.date(TimeEntry.arrival_time) <= end_date)
# Apply project filter
if project_filter:
if project_filter == 'none':
query = query.filter(TimeEntry.project_id.is_(None))
else:
try:
project_id = int(project_filter)
query = query.filter(TimeEntry.project_id == project_id)
except ValueError:
pass
return query.order_by(TimeEntry.arrival_time.desc()).all()
@export_api_bp.route('/analytics/export')
@login_required
@company_required
def analytics_export():
"""Export analytics data in various formats"""
export_format = request.args.get('format', 'csv')
view_type = request.args.get('view', 'table')
mode = request.args.get('mode', 'personal')
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
project_filter = request.args.get('project_id')
# Validate permissions
if mode == 'team':
if not g.user.team_id:
flash('No team assigned', 'error')
return redirect(url_for('analytics'))
if g.user.role not in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN]:
flash('Insufficient permissions', 'error')
return redirect(url_for('analytics'))
try:
# Parse dates
if start_date:
start_date = datetime.strptime(start_date, '%Y-%m-%d').date()
if end_date:
end_date = datetime.strptime(end_date, '%Y-%m-%d').date()
# Get data
data = get_filtered_analytics_data(g.user, mode, start_date, end_date, project_filter)
if export_format == 'csv':
return export_analytics_csv(data, view_type, mode)
elif export_format == 'excel':
return export_analytics_excel(data, view_type, mode)
else:
flash('Invalid export format', 'error')
return redirect(url_for('analytics'))
except Exception as e:
logger.error(f"Error in analytics export: {str(e)}")
flash('Error generating export', 'error')
return redirect(url_for('analytics'))

217
routes/invitations.py Normal file
View File

@@ -0,0 +1,217 @@
"""
Company invitation routes
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, g, jsonify
from models import db, CompanyInvitation, User, Role
from routes.auth import login_required, admin_required
from flask_mail import Message
from datetime import datetime, timedelta
import logging
logger = logging.getLogger(__name__)
invitations_bp = Blueprint('invitations', __name__, url_prefix='/invitations')
@invitations_bp.route('/')
@login_required
@admin_required
def list_invitations():
"""List all invitations for the company"""
invitations = CompanyInvitation.query.filter_by(
company_id=g.user.company_id
).order_by(CompanyInvitation.created_at.desc()).all()
# Separate into pending and accepted
pending_invitations = [inv for inv in invitations if inv.is_valid()]
accepted_invitations = [inv for inv in invitations if inv.accepted]
expired_invitations = [inv for inv in invitations if not inv.accepted and inv.is_expired()]
return render_template('invitations/list.html',
pending_invitations=pending_invitations,
accepted_invitations=accepted_invitations,
expired_invitations=expired_invitations,
title='Manage Invitations')
@invitations_bp.route('/send', methods=['GET', 'POST'])
@login_required
@admin_required
def send_invitation():
"""Send a new invitation"""
if request.method == 'POST':
email = request.form.get('email', '').strip()
role = request.form.get('role', 'Team Member')
custom_message = request.form.get('custom_message', '').strip()
if not email:
flash('Email address is required', 'error')
return redirect(url_for('invitations.send_invitation'))
# Check if user already exists in the company
existing_user = User.query.filter_by(
email=email,
company_id=g.user.company_id
).first()
if existing_user:
flash(f'A user with email {email} already exists in your company', 'error')
return redirect(url_for('invitations.send_invitation'))
# Check for pending invitations
pending_invitation = CompanyInvitation.query.filter_by(
email=email,
company_id=g.user.company_id,
accepted=False
).filter(CompanyInvitation.expires_at > datetime.now()).first()
if pending_invitation:
flash(f'An invitation has already been sent to {email} and is still pending', 'warning')
return redirect(url_for('invitations.list_invitations'))
# Create new invitation
invitation = CompanyInvitation(
company_id=g.user.company_id,
email=email,
role=role,
invited_by_id=g.user.id
)
db.session.add(invitation)
db.session.commit()
# Send invitation email
try:
from app import mail
# Build invitation URL
invitation_url = url_for('register_with_invitation',
token=invitation.token,
_external=True)
msg = Message(
f'Invitation to join {g.user.company.name} on {g.branding.app_name}',
recipients=[email]
)
msg.html = render_template('emails/invitation.html',
invitation=invitation,
invitation_url=invitation_url,
custom_message=custom_message,
sender=g.user)
msg.body = f"""
Hello,
{g.user.username} has invited you to join {g.user.company.name} on {g.branding.app_name}.
{custom_message if custom_message else ''}
Click the link below to accept the invitation and create your account:
{invitation_url}
This invitation will expire in 7 days.
Best regards,
The {g.branding.app_name} Team
"""
mail.send(msg)
logger.info(f"Invitation sent to {email} by {g.user.username}")
flash(f'Invitation sent successfully to {email}', 'success')
except Exception as e:
logger.error(f"Error sending invitation email: {str(e)}")
flash('Invitation created but failed to send email. The user can still use the invitation link.', 'warning')
return redirect(url_for('invitations.list_invitations'))
# GET request - show the form
roles = ['Team Member', 'Team Leader', 'Administrator']
return render_template('invitations/send.html', roles=roles, title='Send Invitation')
@invitations_bp.route('/revoke/<int:invitation_id>', methods=['POST'])
@login_required
@admin_required
def revoke_invitation(invitation_id):
"""Revoke a pending invitation"""
invitation = CompanyInvitation.query.filter_by(
id=invitation_id,
company_id=g.user.company_id,
accepted=False
).first()
if not invitation:
flash('Invitation not found or already accepted', 'error')
return redirect(url_for('invitations.list_invitations'))
# Instead of deleting, we'll expire it immediately
invitation.expires_at = datetime.now()
db.session.commit()
flash(f'Invitation to {invitation.email} has been revoked', 'success')
return redirect(url_for('invitations.list_invitations'))
@invitations_bp.route('/resend/<int:invitation_id>', methods=['POST'])
@login_required
@admin_required
def resend_invitation(invitation_id):
"""Resend an invitation email"""
invitation = CompanyInvitation.query.filter_by(
id=invitation_id,
company_id=g.user.company_id,
accepted=False
).first()
if not invitation:
flash('Invitation not found or already accepted', 'error')
return redirect(url_for('invitations.list_invitations'))
# Extend expiration if needed
if invitation.is_expired():
invitation.expires_at = datetime.now() + timedelta(days=7)
db.session.commit()
# Resend email
try:
from app import mail
invitation_url = url_for('register_with_invitation',
token=invitation.token,
_external=True)
msg = Message(
f'Reminder: Invitation to join {g.user.company.name}',
recipients=[invitation.email]
)
msg.html = render_template('emails/invitation_reminder.html',
invitation=invitation,
invitation_url=invitation_url,
sender=g.user)
msg.body = f"""
Hello,
This is a reminder that you have been invited to join {g.user.company.name} on {g.branding.app_name}.
Click the link below to accept the invitation and create your account:
{invitation_url}
This invitation will expire on {invitation.expires_at.strftime('%B %d, %Y')}.
Best regards,
The {g.branding.app_name} Team
"""
mail.send(msg)
flash(f'Invitation resent to {invitation.email}', 'success')
except Exception as e:
logger.error(f"Error resending invitation email: {str(e)}")
flash('Failed to resend invitation email', 'error')
return redirect(url_for('invitations.list_invitations'))

View File

@@ -195,6 +195,7 @@ def create_note():
tags = request.form.get('tags', '').strip()
project_id = request.form.get('project_id')
task_id = request.form.get('task_id')
is_pinned = request.form.get('is_pinned') == '1'
# Validate
if not title:
@@ -240,9 +241,13 @@ def create_note():
company_id=g.user.company_id,
created_by_id=g.user.id,
project_id=project.id if project else None,
task_id=task.id if task else None
task_id=task.id if task else None,
is_pinned=is_pinned
)
# Generate slug before saving
note.generate_slug()
db.session.add(note)
db.session.commit()
@@ -258,7 +263,7 @@ def create_note():
# Get projects for dropdown
projects = Project.query.filter_by(
company_id=g.user.company_id,
is_archived=False
is_active=True
).order_by(Project.name).all()
# Get task if specified in URL
@@ -359,6 +364,7 @@ def edit_note(slug):
tags = request.form.get('tags', '').strip()
project_id = request.form.get('project_id')
task_id = request.form.get('task_id')
is_pinned = request.form.get('is_pinned') == '1'
# Validate
if not title:
@@ -402,6 +408,7 @@ def edit_note(slug):
note.tags = tags if tags else None
note.project_id = project.id if project else None
note.task_id = task.id if task else None
note.is_pinned = is_pinned
note.updated_at = datetime.now(timezone.utc)
# Update slug if title changed
@@ -421,7 +428,7 @@ def edit_note(slug):
# Get projects for dropdown
projects = Project.query.filter_by(
company_id=g.user.company_id,
is_archived=False
is_active=True
).order_by(Project.name).all()
return render_template('note_editor.html',

View File

@@ -245,6 +245,36 @@ def update_note_folder(slug):
})
@notes_api_bp.route('/<int:note_id>/move', methods=['POST'])
@login_required
@company_required
def move_note_to_folder(note_id):
"""Move a note to a different folder (used by drag and drop)"""
note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first()
if not note:
return jsonify({'success': False, 'error': 'Note not found'}), 404
# Check permissions
if not note.can_user_edit(g.user):
return jsonify({'success': False, 'error': 'Permission denied'}), 403
data = request.get_json()
new_folder = data.get('folder', '').strip()
# Update note folder
note.folder = new_folder if new_folder else None
note.updated_at = datetime.now(timezone.utc)
db.session.commit()
return jsonify({
'success': True,
'message': 'Note moved successfully',
'folder': note.folder or ''
})
@notes_api_bp.route('/<int:note_id>/tags', methods=['POST'])
@login_required
@company_required

238
routes/projects.py Normal file
View File

@@ -0,0 +1,238 @@
"""
Project Management Routes
Handles all project-related views and operations
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, g, abort
from datetime import datetime
from models import db, Project, Team, ProjectCategory, TimeEntry, Role, Task, User
from routes.auth import role_required, company_required, admin_required
from utils.validation import FormValidator
from utils.repository import ProjectRepository
projects_bp = Blueprint('projects', __name__, url_prefix='/admin/projects')
@projects_bp.route('')
@role_required(Role.SUPERVISOR) # Supervisors and Admins can manage projects
@company_required
def admin_projects():
project_repo = ProjectRepository()
projects = project_repo.get_by_company_ordered(g.user.company_id, Project.created_at.desc())
categories = ProjectCategory.query.filter_by(company_id=g.user.company_id).order_by(ProjectCategory.name).all()
return render_template('admin_projects.html', title='Project Management', projects=projects, categories=categories)
@projects_bp.route('/create', methods=['GET', 'POST'])
@role_required(Role.SUPERVISOR)
@company_required
def create_project():
if request.method == 'POST':
validator = FormValidator()
project_repo = ProjectRepository()
name = request.form.get('name')
description = request.form.get('description')
code = request.form.get('code')
team_id = request.form.get('team_id') or None
category_id = request.form.get('category_id') or None
start_date_str = request.form.get('start_date')
end_date_str = request.form.get('end_date')
# Validate required fields
validator.validate_required(name, 'Project name')
validator.validate_required(code, 'Project code')
# Validate code uniqueness
if validator.is_valid():
validator.validate_unique(Project, 'code', code, company_id=g.user.company_id)
# Parse dates
start_date = validator.parse_date(start_date_str, 'Start date')
end_date = validator.parse_date(end_date_str, 'End date')
# Validate date range
if start_date and end_date:
validator.validate_date_range(start_date, end_date)
if validator.is_valid():
project = project_repo.create(
name=name,
description=description,
code=code.upper(),
company_id=g.user.company_id,
team_id=int(team_id) if team_id else None,
category_id=int(category_id) if category_id else None,
start_date=start_date,
end_date=end_date,
created_by_id=g.user.id
)
project_repo.save()
flash(f'Project "{name}" created successfully!', 'success')
return redirect(url_for('projects.admin_projects'))
else:
validator.flash_errors()
# Get available teams and categories for the form (company-scoped)
teams = Team.query.filter_by(company_id=g.user.company_id).order_by(Team.name).all()
categories = ProjectCategory.query.filter_by(company_id=g.user.company_id).order_by(ProjectCategory.name).all()
return render_template('create_project.html', title='Create Project', teams=teams, categories=categories)
@projects_bp.route('/edit/<int:project_id>', methods=['GET', 'POST'])
@role_required(Role.SUPERVISOR)
@company_required
def edit_project(project_id):
project_repo = ProjectRepository()
project = project_repo.get_by_id_and_company(project_id, g.user.company_id)
if not project:
abort(404)
if request.method == 'POST':
validator = FormValidator()
name = request.form.get('name')
description = request.form.get('description')
code = request.form.get('code')
team_id = request.form.get('team_id') or None
category_id = request.form.get('category_id') or None
is_active = request.form.get('is_active') == 'on'
start_date_str = request.form.get('start_date')
end_date_str = request.form.get('end_date')
# Validate required fields
validator.validate_required(name, 'Project name')
validator.validate_required(code, 'Project code')
# Validate code uniqueness (exclude current project)
if validator.is_valid() and code != project.code:
validator.validate_unique(Project, 'code', code, company_id=g.user.company_id)
# Parse dates
start_date = validator.parse_date(start_date_str, 'Start date')
end_date = validator.parse_date(end_date_str, 'End date')
# Validate date range
if start_date and end_date:
validator.validate_date_range(start_date, end_date)
if validator.is_valid():
project_repo.update(project,
name=name,
description=description,
code=code.upper(),
team_id=int(team_id) if team_id else None,
category_id=int(category_id) if category_id else None,
is_active=is_active,
start_date=start_date,
end_date=end_date
)
project_repo.save()
flash(f'Project "{name}" updated successfully!', 'success')
return redirect(url_for('projects.admin_projects'))
else:
validator.flash_errors()
# Get available teams and categories for the form (company-scoped)
teams = Team.query.filter_by(company_id=g.user.company_id).order_by(Team.name).all()
categories = ProjectCategory.query.filter_by(company_id=g.user.company_id).order_by(ProjectCategory.name).all()
return render_template('edit_project.html', title='Edit Project', project=project, teams=teams, categories=categories)
@projects_bp.route('/delete/<int:project_id>', methods=['POST'])
@company_required
def delete_project(project_id):
# Check if user is admin or system admin
if g.user.role not in [Role.ADMIN, Role.SYSTEM_ADMIN]:
flash('You do not have permission to delete projects.', 'error')
return redirect(url_for('projects.admin_projects'))
project_repo = ProjectRepository()
project = project_repo.get_by_id_and_company(project_id, g.user.company_id)
if not project:
abort(404)
project_name = project.name
try:
# Import models needed for cascading deletions
from models import Sprint, SubTask, TaskDependency, Comment
# Delete all related data in the correct order
# Delete comments on tasks in this project
Comment.query.filter(Comment.task_id.in_(
db.session.query(Task.id).filter(Task.project_id == project_id)
)).delete(synchronize_session=False)
# Delete subtasks
SubTask.query.filter(SubTask.task_id.in_(
db.session.query(Task.id).filter(Task.project_id == project_id)
)).delete(synchronize_session=False)
# Delete task dependencies
TaskDependency.query.filter(
TaskDependency.blocked_task_id.in_(
db.session.query(Task.id).filter(Task.project_id == project_id)
) | TaskDependency.blocking_task_id.in_(
db.session.query(Task.id).filter(Task.project_id == project_id)
)
).delete(synchronize_session=False)
# Delete tasks
Task.query.filter_by(project_id=project_id).delete()
# Delete sprints
Sprint.query.filter_by(project_id=project_id).delete()
# Delete time entries
TimeEntry.query.filter_by(project_id=project_id).delete()
# Finally, delete the project
project_repo.delete(project)
db.session.commit()
flash(f'Project "{project_name}" and all related data have been permanently deleted.', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error deleting project: {str(e)}', 'error')
return redirect(url_for('projects.edit_project', project_id=project_id))
return redirect(url_for('projects.admin_projects'))
@projects_bp.route('/<int:project_id>/tasks')
@role_required(Role.TEAM_MEMBER) # All authenticated users can view tasks
@company_required
def manage_project_tasks(project_id):
project_repo = ProjectRepository()
project = project_repo.get_by_id_and_company(project_id, g.user.company_id)
if not project:
abort(404)
# Check if user has access to this project
if not project.is_user_allowed(g.user):
flash('You do not have access to this project.', 'error')
return redirect(url_for('projects.admin_projects'))
# Get all tasks for this project
tasks = Task.query.filter_by(project_id=project_id).order_by(Task.created_at.desc()).all()
# Get team members for assignment dropdown
if project.team_id:
# If project is assigned to a specific team, only show team members
team_members = User.query.filter_by(team_id=project.team_id, company_id=g.user.company_id).all()
else:
# If project is available to all teams, show all company users
team_members = User.query.filter_by(company_id=g.user.company_id).all()
return render_template('manage_project_tasks.html',
title=f'Tasks - {project.name}',
project=project,
tasks=tasks,
team_members=team_members)

170
routes/projects_api.py Normal file
View File

@@ -0,0 +1,170 @@
"""
Project Management API Routes
Handles all project-related API endpoints including categories
"""
from flask import Blueprint, jsonify, request, g
from sqlalchemy import or_ as sql_or
from models import db, Project, ProjectCategory, Role
from routes.auth import role_required, company_required, admin_required
import logging
logger = logging.getLogger(__name__)
projects_api_bp = Blueprint('projects_api', __name__, url_prefix='/api')
# Category Management API Routes
@projects_api_bp.route('/admin/categories', methods=['POST'])
@role_required(Role.ADMIN)
@company_required
def create_category():
try:
data = request.get_json()
name = data.get('name')
description = data.get('description', '')
color = data.get('color', '#007bff')
icon = data.get('icon', '')
if not name:
return jsonify({'success': False, 'message': 'Category name is required'})
# Check if category already exists
existing = ProjectCategory.query.filter_by(
name=name,
company_id=g.user.company_id
).first()
if existing:
return jsonify({'success': False, 'message': 'Category name already exists'})
category = ProjectCategory(
name=name,
description=description,
color=color,
icon=icon,
company_id=g.user.company_id,
created_by_id=g.user.id
)
db.session.add(category)
db.session.commit()
return jsonify({'success': True, 'message': 'Category created successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
@projects_api_bp.route('/admin/categories/<int:category_id>', methods=['PUT'])
@role_required(Role.ADMIN)
@company_required
def update_category(category_id):
try:
category = ProjectCategory.query.filter_by(
id=category_id,
company_id=g.user.company_id
).first()
if not category:
return jsonify({'success': False, 'message': 'Category not found'})
data = request.get_json()
name = data.get('name')
if not name:
return jsonify({'success': False, 'message': 'Category name is required'})
# Check if name conflicts with another category
existing = ProjectCategory.query.filter(
ProjectCategory.name == name,
ProjectCategory.company_id == g.user.company_id,
ProjectCategory.id != category_id
).first()
if existing:
return jsonify({'success': False, 'message': 'Category name already exists'})
category.name = name
category.description = data.get('description', '')
category.color = data.get('color', category.color)
category.icon = data.get('icon', '')
db.session.commit()
return jsonify({'success': True, 'message': 'Category updated successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
@projects_api_bp.route('/admin/categories/<int:category_id>', methods=['DELETE'])
@role_required(Role.ADMIN)
@company_required
def delete_category(category_id):
try:
category = ProjectCategory.query.filter_by(
id=category_id,
company_id=g.user.company_id
).first()
if not category:
return jsonify({'success': False, 'message': 'Category not found'})
# Unassign projects from this category
projects = Project.query.filter_by(category_id=category_id).all()
for project in projects:
project.category_id = None
db.session.delete(category)
db.session.commit()
return jsonify({'success': True, 'message': 'Category deleted successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
@projects_api_bp.route('/search/projects')
@role_required(Role.TEAM_MEMBER)
@company_required
def search_projects():
"""Search for projects for smart search auto-completion"""
try:
query = request.args.get('q', '').strip()
if not query:
return jsonify({'success': True, 'projects': []})
# Search projects the user has access to
projects = Project.query.filter(
Project.company_id == g.user.company_id,
sql_or(
Project.code.ilike(f'%{query}%'),
Project.name.ilike(f'%{query}%')
)
).limit(10).all()
# Filter projects user has access to
accessible_projects = [
project for project in projects
if project.is_user_allowed(g.user)
]
project_list = [
{
'id': project.id,
'code': project.code,
'name': project.name
}
for project in accessible_projects
]
return jsonify({'success': True, 'projects': project_list})
except Exception as e:
logger.error(f"Error in search_projects: {str(e)}")
return jsonify({'success': False, 'message': str(e)})

47
routes/sprints.py Normal file
View File

@@ -0,0 +1,47 @@
"""
Sprint Management Routes
Handles all sprint-related views
"""
from flask import Blueprint, render_template, g, redirect, url_for, flash
from sqlalchemy import or_
from models import db, Role, Project, Sprint
from routes.auth import login_required, role_required, company_required
sprints_bp = Blueprint('sprints', __name__, url_prefix='/sprints')
@sprints_bp.route('')
@role_required(Role.TEAM_MEMBER)
@company_required
def sprint_management():
"""Sprint management interface"""
# Get all projects the user has access to (for sprint assignment)
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
# Admins and Supervisors can see all company projects
available_projects = Project.query.filter_by(
company_id=g.user.company_id,
is_active=True
).order_by(Project.name).all()
elif g.user.team_id:
# Team members see team projects + unassigned projects
available_projects = Project.query.filter(
Project.company_id == g.user.company_id,
Project.is_active == True,
or_(Project.team_id == g.user.team_id, Project.team_id == None)
).order_by(Project.name).all()
# Filter by actual access permissions
available_projects = [p for p in available_projects if p.is_user_allowed(g.user)]
else:
# Unassigned users see only unassigned projects
available_projects = Project.query.filter_by(
company_id=g.user.company_id,
team_id=None,
is_active=True
).order_by(Project.name).all()
available_projects = [p for p in available_projects if p.is_user_allowed(g.user)]
return render_template('sprint_management.html',
title='Sprint Management',
available_projects=available_projects)

224
routes/sprints_api.py Normal file
View File

@@ -0,0 +1,224 @@
"""
Sprint Management API Routes
Handles all sprint-related API endpoints
"""
from flask import Blueprint, jsonify, request, g
from datetime import datetime
from models import db, Role, Project, Sprint, SprintStatus, Task
from routes.auth import login_required, role_required, company_required
import logging
logger = logging.getLogger(__name__)
sprints_api_bp = Blueprint('sprints_api', __name__, url_prefix='/api')
@sprints_api_bp.route('/sprints')
@role_required(Role.TEAM_MEMBER)
@company_required
def get_sprints():
"""Get all sprints for the user's company"""
try:
# Base query for sprints in user's company
query = Sprint.query.filter(Sprint.company_id == g.user.company_id)
# Apply access restrictions based on user role and team
if g.user.role not in [Role.ADMIN, Role.SUPERVISOR]:
# Regular users can only see sprints they have access to
accessible_sprint_ids = []
sprints = query.all()
for sprint in sprints:
if sprint.can_user_access(g.user):
accessible_sprint_ids.append(sprint.id)
if accessible_sprint_ids:
query = query.filter(Sprint.id.in_(accessible_sprint_ids))
else:
# No accessible sprints, return empty list
return jsonify({'success': True, 'sprints': []})
sprints = query.order_by(Sprint.created_at.desc()).all()
sprint_list = []
for sprint in sprints:
task_summary = sprint.get_task_summary()
sprint_data = {
'id': sprint.id,
'name': sprint.name,
'description': sprint.description,
'status': sprint.status.name,
'company_id': sprint.company_id,
'project_id': sprint.project_id,
'project_name': sprint.project.name if sprint.project else None,
'project_code': sprint.project.code if sprint.project else None,
'start_date': sprint.start_date.isoformat(),
'end_date': sprint.end_date.isoformat(),
'goal': sprint.goal,
'capacity_hours': sprint.capacity_hours,
'created_by_id': sprint.created_by_id,
'created_by_name': sprint.created_by.username if sprint.created_by else None,
'created_at': sprint.created_at.isoformat(),
'is_current': sprint.is_current,
'duration_days': sprint.duration_days,
'days_remaining': sprint.days_remaining,
'progress_percentage': sprint.progress_percentage,
'task_summary': task_summary
}
sprint_list.append(sprint_data)
return jsonify({'success': True, 'sprints': sprint_list})
except Exception as e:
logger.error(f"Error in get_sprints: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
@sprints_api_bp.route('/sprints', methods=['POST'])
@role_required(Role.TEAM_LEADER) # Team leaders and above can create sprints
@company_required
def create_sprint():
"""Create a new sprint"""
try:
data = request.get_json()
# Validate required fields
name = data.get('name')
start_date = data.get('start_date')
end_date = data.get('end_date')
if not name:
return jsonify({'success': False, 'message': 'Sprint name is required'})
if not start_date:
return jsonify({'success': False, 'message': 'Start date is required'})
if not end_date:
return jsonify({'success': False, 'message': 'End date is required'})
# Parse dates
try:
start_date = datetime.strptime(start_date, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date, '%Y-%m-%d').date()
except ValueError:
return jsonify({'success': False, 'message': 'Invalid date format'})
if start_date >= end_date:
return jsonify({'success': False, 'message': 'End date must be after start date'})
# Verify project access if project is specified
project_id = data.get('project_id')
if project_id:
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first()
if not project or not project.is_user_allowed(g.user):
return jsonify({'success': False, 'message': 'Project not found or access denied'})
# Create sprint
sprint = Sprint(
name=name,
description=data.get('description', ''),
status=SprintStatus[data.get('status', 'PLANNING')],
company_id=g.user.company_id,
project_id=int(project_id) if project_id else None,
start_date=start_date,
end_date=end_date,
goal=data.get('goal'),
capacity_hours=int(data.get('capacity_hours')) if data.get('capacity_hours') else None,
created_by_id=g.user.id
)
db.session.add(sprint)
db.session.commit()
return jsonify({'success': True, 'message': 'Sprint created successfully'})
except Exception as e:
db.session.rollback()
logger.error(f"Error creating sprint: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
@sprints_api_bp.route('/sprints/<int:sprint_id>', methods=['PUT'])
@role_required(Role.TEAM_LEADER)
@company_required
def update_sprint(sprint_id):
"""Update an existing sprint"""
try:
sprint = Sprint.query.filter_by(id=sprint_id, company_id=g.user.company_id).first()
if not sprint or not sprint.can_user_access(g.user):
return jsonify({'success': False, 'message': 'Sprint not found or access denied'})
data = request.get_json()
# Update sprint fields
if 'name' in data:
sprint.name = data['name']
if 'description' in data:
sprint.description = data['description']
if 'status' in data:
sprint.status = SprintStatus[data['status']]
if 'goal' in data:
sprint.goal = data['goal']
if 'capacity_hours' in data:
sprint.capacity_hours = int(data['capacity_hours']) if data['capacity_hours'] else None
if 'project_id' in data:
project_id = data['project_id']
if project_id:
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first()
if not project or not project.is_user_allowed(g.user):
return jsonify({'success': False, 'message': 'Project not found or access denied'})
sprint.project_id = int(project_id)
else:
sprint.project_id = None
# Update dates if provided
if 'start_date' in data:
try:
sprint.start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date()
except ValueError:
return jsonify({'success': False, 'message': 'Invalid start date format'})
if 'end_date' in data:
try:
sprint.end_date = datetime.strptime(data['end_date'], '%Y-%m-%d').date()
except ValueError:
return jsonify({'success': False, 'message': 'Invalid end date format'})
# Validate date order
if sprint.start_date >= sprint.end_date:
return jsonify({'success': False, 'message': 'End date must be after start date'})
db.session.commit()
return jsonify({'success': True, 'message': 'Sprint updated successfully'})
except Exception as e:
db.session.rollback()
logger.error(f"Error updating sprint: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
@sprints_api_bp.route('/sprints/<int:sprint_id>', methods=['DELETE'])
@role_required(Role.TEAM_LEADER)
@company_required
def delete_sprint(sprint_id):
"""Delete a sprint and remove it from all associated tasks"""
try:
sprint = Sprint.query.filter_by(id=sprint_id, company_id=g.user.company_id).first()
if not sprint or not sprint.can_user_access(g.user):
return jsonify({'success': False, 'message': 'Sprint not found or access denied'})
# Remove sprint assignment from all tasks
Task.query.filter_by(sprint_id=sprint_id).update({'sprint_id': None})
# Delete the sprint
db.session.delete(sprint)
db.session.commit()
return jsonify({'success': True, 'message': 'Sprint deleted successfully'})
except Exception as e:
db.session.rollback()
logger.error(f"Error deleting sprint: {str(e)}")
return jsonify({'success': False, 'message': str(e)})

511
routes/system_admin.py Normal file
View File

@@ -0,0 +1,511 @@
"""
System Administrator routes
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, g, current_app
from models import (db, Company, User, Role, Team, Project, TimeEntry, SystemSettings,
SystemEvent, BrandingSettings, Task, SubTask, TaskDependency, Sprint,
Comment, UserPreferences, UserDashboard, WorkConfig, CompanySettings,
CompanyWorkConfig, ProjectCategory)
from routes.auth import system_admin_required
from flask import session
from sqlalchemy import func
from datetime import datetime, timedelta
import logging
import os
logger = logging.getLogger(__name__)
system_admin_bp = Blueprint('system_admin', __name__, url_prefix='/system-admin')
@system_admin_bp.route('/dashboard')
@system_admin_required
def system_admin_dashboard():
"""System Administrator Dashboard - view all data across companies"""
# Global statistics
total_companies = Company.query.count()
total_users = User.query.count()
total_teams = Team.query.count()
total_projects = Project.query.count()
total_time_entries = TimeEntry.query.count()
# System admin count
system_admins = User.query.filter_by(role=Role.SYSTEM_ADMIN).count()
regular_admins = User.query.filter_by(role=Role.ADMIN).count()
# Recent activity (last 7 days)
week_ago = datetime.now() - timedelta(days=7)
recent_users = User.query.filter(User.created_at >= week_ago).count()
recent_companies = Company.query.filter(Company.created_at >= week_ago).count()
recent_time_entries = TimeEntry.query.filter(TimeEntry.arrival_time >= week_ago).count()
# Top companies by user count
top_companies = db.session.query(
Company.name,
Company.id,
db.func.count(User.id).label('user_count')
).join(User).group_by(Company.id).order_by(db.func.count(User.id).desc()).limit(5).all()
# Recent companies
recent_companies_list = Company.query.order_by(Company.created_at.desc()).limit(5).all()
# System health checks
orphaned_users = User.query.filter_by(company_id=None).count()
orphaned_time_entries = TimeEntry.query.filter_by(user_id=None).count()
blocked_users = User.query.filter_by(is_blocked=True).count()
return render_template('system_admin_dashboard.html',
title='System Administrator Dashboard',
total_companies=total_companies,
total_users=total_users,
total_teams=total_teams,
total_projects=total_projects,
total_time_entries=total_time_entries,
system_admins=system_admins,
regular_admins=regular_admins,
recent_users=recent_users,
recent_companies=recent_companies,
recent_time_entries=recent_time_entries,
top_companies=top_companies,
recent_companies_list=recent_companies_list,
orphaned_users=orphaned_users,
orphaned_time_entries=orphaned_time_entries,
blocked_users=blocked_users)
@system_admin_bp.route('/companies')
@system_admin_required
def system_admin_companies():
"""System admin view of all companies"""
# Get filter parameters
search_query = request.args.get('search', '')
# Base query
query = Company.query
# Apply search filter
if search_query:
query = query.filter(
db.or_(
Company.name.ilike(f'%{search_query}%'),
Company.slug.ilike(f'%{search_query}%')
)
)
# Get all companies
companies = query.order_by(Company.created_at.desc()).all()
# Create a paginated response object
from flask_sqlalchemy import Pagination
page = request.args.get('page', 1, type=int)
per_page = 20
# Paginate companies
companies_paginated = query.paginate(page=page, per_page=per_page, error_out=False)
# Calculate statistics for each company
company_stats = {}
for company in companies_paginated.items:
company_stats[company.id] = {
'user_count': User.query.filter_by(company_id=company.id).count(),
'admin_count': User.query.filter_by(company_id=company.id, role=Role.ADMIN).count(),
'team_count': Team.query.filter_by(company_id=company.id).count(),
'project_count': Project.query.filter_by(company_id=company.id).count(),
'active_projects': Project.query.filter_by(company_id=company.id, is_active=True).count(),
}
return render_template('system_admin_companies.html',
title='System Admin - Companies',
companies=companies_paginated,
company_stats=company_stats,
search_query=search_query)
@system_admin_bp.route('/companies/<int:company_id>')
@system_admin_required
def system_admin_company_detail(company_id):
"""System admin detailed view of a specific company"""
company = Company.query.get_or_404(company_id)
# Get recent time entries count
week_ago = datetime.now() - timedelta(days=7)
recent_time_entries = TimeEntry.query.join(User).filter(
User.company_id == company.id,
TimeEntry.arrival_time >= week_ago
).count()
# Get role distribution
role_counts = {}
for role in Role:
count = User.query.filter_by(company_id=company.id, role=role).count()
if count > 0:
role_counts[role.value] = count
# Get users list
users = User.query.filter_by(company_id=company.id).order_by(User.created_at.desc()).all()
# Get teams list
teams = Team.query.filter_by(company_id=company.id).all()
# Get projects list
projects = Project.query.filter_by(company_id=company.id).order_by(Project.created_at.desc()).all()
return render_template('system_admin_company_detail.html',
title=f'Company Details - {company.name}',
company=company,
users=users,
teams=teams,
projects=projects,
recent_time_entries=recent_time_entries,
role_counts=role_counts)
@system_admin_bp.route('/companies/<int:company_id>/delete', methods=['POST'])
@system_admin_required
def delete_company(company_id):
"""System Admin: Delete a company and all its data"""
company = Company.query.get_or_404(company_id)
company_name = company.name
try:
# Delete all related data in the correct order to avoid foreign key constraints
# Delete comments (must be before tasks)
Comment.query.filter(Comment.task_id.in_(
db.session.query(Task.id).join(Project).filter(Project.company_id == company_id)
)).delete(synchronize_session=False)
# Delete subtasks
SubTask.query.filter(SubTask.task_id.in_(
db.session.query(Task.id).join(Project).filter(Project.company_id == company_id)
)).delete(synchronize_session=False)
# Delete task dependencies
TaskDependency.query.filter(
TaskDependency.blocked_task_id.in_(
db.session.query(Task.id).join(Project).filter(Project.company_id == company_id)
) | TaskDependency.blocking_task_id.in_(
db.session.query(Task.id).join(Project).filter(Project.company_id == company_id)
)
).delete(synchronize_session=False)
# Delete tasks
Task.query.filter(Task.project_id.in_(
db.session.query(Project.id).filter(Project.company_id == company_id)
)).delete(synchronize_session=False)
# Delete sprints
Sprint.query.filter(Sprint.project_id.in_(
db.session.query(Project.id).filter(Project.company_id == company_id)
)).delete(synchronize_session=False)
# Delete time entries
TimeEntry.query.filter(TimeEntry.user_id.in_(
db.session.query(User.id).filter(User.company_id == company_id)
)).delete(synchronize_session=False)
# Delete projects
Project.query.filter_by(company_id=company_id).delete()
# Delete teams
Team.query.filter_by(company_id=company_id).delete()
# Delete user preferences, dashboards, and work configs
UserPreferences.query.filter(UserPreferences.user_id.in_(
db.session.query(User.id).filter(User.company_id == company_id)
)).delete(synchronize_session=False)
UserDashboard.query.filter(UserDashboard.user_id.in_(
db.session.query(User.id).filter(User.company_id == company_id)
)).delete(synchronize_session=False)
WorkConfig.query.filter(WorkConfig.user_id.in_(
db.session.query(User.id).filter(User.company_id == company_id)
)).delete(synchronize_session=False)
# Delete users
User.query.filter_by(company_id=company_id).delete()
# Delete company settings and work config
CompanySettings.query.filter_by(company_id=company_id).delete()
CompanyWorkConfig.query.filter_by(company_id=company_id).delete()
# Delete project categories
ProjectCategory.query.filter_by(company_id=company_id).delete()
# Finally, delete the company
db.session.delete(company)
db.session.commit()
flash(f'Company "{company_name}" and all its data have been permanently deleted.', 'success')
logger.info(f"System admin {g.user.username} deleted company {company_name} (ID: {company_id})")
except Exception as e:
db.session.rollback()
logger.error(f"Error deleting company {company_id}: {str(e)}")
flash(f'Error deleting company: {str(e)}', 'error')
return redirect(url_for('system_admin.system_admin_company_detail', company_id=company_id))
return redirect(url_for('system_admin.system_admin_companies'))
@system_admin_bp.route('/time-entries')
@system_admin_required
def system_admin_time_entries():
"""System Admin: View time entries across all companies"""
page = request.args.get('page', 1, type=int)
company_filter = request.args.get('company', '')
per_page = 50
# Build query properly with explicit joins
query = db.session.query(
TimeEntry,
User.username,
Company.name.label('company_name'),
Project.name.label('project_name')
).join(
User, TimeEntry.user_id == User.id
).join(
Company, User.company_id == Company.id
).outerjoin(
Project, TimeEntry.project_id == Project.id
)
# Apply company filter
if company_filter:
query = query.filter(Company.id == company_filter)
# Order by arrival time (newest first)
query = query.order_by(TimeEntry.arrival_time.desc())
# Paginate
entries = query.paginate(page=page, per_page=per_page, error_out=False)
# Get companies for filter dropdown
companies = Company.query.order_by(Company.name).all()
# Get today's date for the template
today = datetime.now().date()
return render_template('system_admin_time_entries.html',
title='System Admin - Time Entries',
entries=entries,
companies=companies,
current_company=company_filter,
today=today)
@system_admin_bp.route('/settings', methods=['GET', 'POST'])
@system_admin_required
def system_admin_settings():
"""System Admin: Global system settings"""
if request.method == 'POST':
# Update system settings
registration_enabled = request.form.get('registration_enabled') == 'on'
email_verification = request.form.get('email_verification_required') == 'on'
tracking_script_enabled = request.form.get('tracking_script_enabled') == 'on'
tracking_script_code = request.form.get('tracking_script_code', '')
# Update or create settings
reg_setting = SystemSettings.query.filter_by(key='registration_enabled').first()
if reg_setting:
reg_setting.value = 'true' if registration_enabled else 'false'
else:
reg_setting = SystemSettings(
key='registration_enabled',
value='true' if registration_enabled else 'false',
description='Controls whether new user registration is allowed'
)
db.session.add(reg_setting)
email_setting = SystemSettings.query.filter_by(key='email_verification_required').first()
if email_setting:
email_setting.value = 'true' if email_verification else 'false'
else:
email_setting = SystemSettings(
key='email_verification_required',
value='true' if email_verification else 'false',
description='Controls whether email verification is required for new accounts'
)
db.session.add(email_setting)
tracking_enabled_setting = SystemSettings.query.filter_by(key='tracking_script_enabled').first()
if tracking_enabled_setting:
tracking_enabled_setting.value = 'true' if tracking_script_enabled else 'false'
else:
tracking_enabled_setting = SystemSettings(
key='tracking_script_enabled',
value='true' if tracking_script_enabled else 'false',
description='Controls whether custom tracking script is enabled'
)
db.session.add(tracking_enabled_setting)
tracking_code_setting = SystemSettings.query.filter_by(key='tracking_script_code').first()
if tracking_code_setting:
tracking_code_setting.value = tracking_script_code
else:
tracking_code_setting = SystemSettings(
key='tracking_script_code',
value=tracking_script_code,
description='Custom tracking script code (HTML/JavaScript)'
)
db.session.add(tracking_code_setting)
db.session.commit()
flash('System settings updated successfully.', 'success')
return redirect(url_for('system_admin.system_admin_settings'))
# Get current settings
settings = {}
all_settings = SystemSettings.query.all()
for setting in all_settings:
if setting.key == 'registration_enabled':
settings['registration_enabled'] = setting.value == 'true'
elif setting.key == 'email_verification_required':
settings['email_verification_required'] = setting.value == 'true'
elif setting.key == 'tracking_script_enabled':
settings['tracking_script_enabled'] = setting.value == 'true'
elif setting.key == 'tracking_script_code':
settings['tracking_script_code'] = setting.value
# System statistics
total_companies = Company.query.count()
total_users = User.query.count()
total_system_admins = User.query.filter_by(role=Role.SYSTEM_ADMIN).count()
return render_template('system_admin_settings.html',
title='System Administrator Settings',
settings=settings,
total_companies=total_companies,
total_users=total_users,
total_system_admins=total_system_admins)
@system_admin_bp.route('/health')
@system_admin_required
def system_admin_health():
"""System Admin: System health check and event log"""
# Get system health summary
health_summary = SystemEvent.get_system_health_summary()
# Get recent events (last 7 days)
recent_events = SystemEvent.get_recent_events(days=7, limit=100)
# Get events by severity for quick stats
errors = SystemEvent.get_events_by_severity('error', days=7, limit=20)
warnings = SystemEvent.get_events_by_severity('warning', days=7, limit=20)
# System metrics
now = datetime.now()
# Database connection test
db_healthy = True
db_error = None
try:
db.session.execute('SELECT 1')
except Exception as e:
db_healthy = False
db_error = str(e)
SystemEvent.log_event(
'database_check_failed',
f'Database health check failed: {str(e)}',
'system',
'error'
)
# Application uptime (approximate based on first event)
first_event = SystemEvent.query.order_by(SystemEvent.timestamp.asc()).first()
uptime_start = first_event.timestamp if first_event else now
uptime_duration = now - uptime_start
# Recent activity stats
today = now.date()
today_events = SystemEvent.query.filter(
func.date(SystemEvent.timestamp) == today
).count()
# Log the health check
SystemEvent.log_event(
'system_health_check',
f'System health check performed by {session.get("username", "unknown")}',
'system',
'info',
user_id=session.get('user_id'),
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
return render_template('system_admin_health.html',
title='System Health Check',
health_summary=health_summary,
recent_events=recent_events,
errors=errors,
warnings=warnings,
db_healthy=db_healthy,
db_error=db_error,
uptime_duration=uptime_duration,
today_events=today_events)
@system_admin_bp.route('/branding', methods=['GET', 'POST'])
@system_admin_required
def branding():
"""System Admin: Branding settings"""
if request.method == 'POST':
branding = BrandingSettings.get_current()
# Handle form data
branding.app_name = request.form.get('app_name', g.branding.app_name).strip()
branding.logo_alt_text = request.form.get('logo_alt_text', '').strip()
branding.primary_color = request.form.get('primary_color', '#007bff').strip()
# Handle imprint settings
branding.imprint_enabled = 'imprint_enabled' in request.form
branding.imprint_title = request.form.get('imprint_title', 'Imprint').strip()
branding.imprint_content = request.form.get('imprint_content', '').strip()
branding.updated_by_id = g.user.id
# Handle logo upload
if 'logo_file' in request.files:
logo_file = request.files['logo_file']
if logo_file and logo_file.filename:
# Create uploads directory if it doesn't exist
upload_dir = os.path.join(current_app.static_folder, 'uploads', 'branding')
os.makedirs(upload_dir, exist_ok=True)
# Save the file with a timestamp to avoid conflicts
import time
filename = f"logo_{int(time.time())}_{logo_file.filename}"
logo_path = os.path.join(upload_dir, filename)
logo_file.save(logo_path)
branding.logo_filename = filename
# Handle favicon upload
if 'favicon_file' in request.files:
favicon_file = request.files['favicon_file']
if favicon_file and favicon_file.filename:
# Create uploads directory if it doesn't exist
upload_dir = os.path.join(current_app.static_folder, 'uploads', 'branding')
os.makedirs(upload_dir, exist_ok=True)
# Save the file with a timestamp to avoid conflicts
import time
filename = f"favicon_{int(time.time())}_{favicon_file.filename}"
favicon_path = os.path.join(upload_dir, filename)
favicon_file.save(favicon_path)
branding.favicon_filename = filename
db.session.commit()
flash('Branding settings updated successfully.', 'success')
return redirect(url_for('system_admin.branding'))
# Get current branding settings
branding = BrandingSettings.get_current()
return render_template('system_admin_branding.html',
title='System Administrator - Branding Settings',
branding=branding)

128
routes/tasks.py Normal file
View File

@@ -0,0 +1,128 @@
"""
Task Management Routes
Handles all task-related views and operations
"""
from flask import Blueprint, render_template, g, redirect, url_for, flash
from sqlalchemy import or_
from models import db, Role, Project, Task, User
from routes.auth import login_required, role_required, company_required
tasks_bp = Blueprint('tasks', __name__, url_prefix='/tasks')
def get_filtered_tasks_for_burndown(user, mode, start_date=None, end_date=None, project_filter=None):
"""Get filtered tasks for burndown chart"""
from datetime import datetime, time
# Base query - get tasks from user's company
query = Task.query.join(Project).filter(Project.company_id == user.company_id)
# Apply user/team filter
if mode == 'personal':
# For personal mode, get tasks assigned to the user or created by them
query = query.filter(
(Task.assigned_to_id == user.id) |
(Task.created_by_id == user.id)
)
elif mode == 'team' and user.team_id:
# For team mode, get tasks from projects assigned to the team
query = query.filter(Project.team_id == user.team_id)
# Apply project filter
if project_filter:
if project_filter == 'none':
# No project filter for tasks - they must belong to a project
return []
else:
try:
project_id = int(project_filter)
query = query.filter(Task.project_id == project_id)
except ValueError:
pass
# Apply date filters - use task creation date and completion date
if start_date:
query = query.filter(
(Task.created_at >= datetime.combine(start_date, time.min)) |
(Task.completed_date >= start_date)
)
if end_date:
query = query.filter(
Task.created_at <= datetime.combine(end_date, time.max)
)
return query.order_by(Task.created_at.desc()).all()
@tasks_bp.route('')
@role_required(Role.TEAM_MEMBER)
@company_required
def unified_task_management():
"""Unified task management interface"""
# Get all projects the user has access to (for filtering and task creation)
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
# Admins and Supervisors can see all company projects
available_projects = Project.query.filter_by(
company_id=g.user.company_id,
is_active=True
).order_by(Project.name).all()
elif g.user.team_id:
# Team members see team projects + unassigned projects
available_projects = Project.query.filter(
Project.company_id == g.user.company_id,
Project.is_active == True,
or_(Project.team_id == g.user.team_id, Project.team_id == None)
).order_by(Project.name).all()
# Filter by actual access permissions
available_projects = [p for p in available_projects if p.is_user_allowed(g.user)]
else:
# Unassigned users see only unassigned projects
available_projects = Project.query.filter_by(
company_id=g.user.company_id,
team_id=None,
is_active=True
).order_by(Project.name).all()
available_projects = [p for p in available_projects if p.is_user_allowed(g.user)]
# Get team members for task assignment (company-scoped)
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
# Admins can assign to anyone in the company
team_members = User.query.filter_by(
company_id=g.user.company_id,
is_blocked=False
).order_by(User.username).all()
elif g.user.team_id:
# Team members can assign to team members + supervisors/admins
team_members = User.query.filter(
User.company_id == g.user.company_id,
User.is_blocked == False,
or_(
User.team_id == g.user.team_id,
User.role.in_([Role.ADMIN, Role.SUPERVISOR])
)
).order_by(User.username).all()
else:
# Unassigned users can assign to supervisors/admins only
team_members = User.query.filter(
User.company_id == g.user.company_id,
User.is_blocked == False,
User.role.in_([Role.ADMIN, Role.SUPERVISOR])
).order_by(User.username).all()
# Convert team members to JSON-serializable format
team_members_data = [{
'id': member.id,
'username': member.username,
'email': member.email,
'role': member.role.value if member.role else 'Team Member',
'avatar_url': member.get_avatar_url(32)
} for member in team_members]
return render_template('unified_task_management.html',
title='Task Management',
available_projects=available_projects,
team_members=team_members_data)

985
routes/tasks_api.py Normal file
View File

@@ -0,0 +1,985 @@
"""
Task Management API Routes
Handles all task-related API endpoints including subtasks and dependencies
"""
from flask import Blueprint, jsonify, request, g
from datetime import datetime
from models import (db, Role, Project, Task, User, TaskStatus, TaskPriority, SubTask,
TaskDependency, Sprint, CompanySettings, Comment, CommentVisibility)
from routes.auth import login_required, role_required, company_required
import logging
logger = logging.getLogger(__name__)
tasks_api_bp = Blueprint('tasks_api', __name__, url_prefix='/api')
@tasks_api_bp.route('/tasks', methods=['POST'])
@role_required(Role.TEAM_MEMBER)
@company_required
def create_task():
try:
data = request.get_json()
project_id = data.get('project_id')
# Verify project access
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first()
if not project or not project.is_user_allowed(g.user):
return jsonify({'success': False, 'message': 'Project not found or access denied'})
# Validate required fields
name = data.get('name')
if not name:
return jsonify({'success': False, 'message': 'Task name is required'})
# Parse dates
start_date = None
due_date = None
if data.get('start_date'):
start_date = datetime.strptime(data.get('start_date'), '%Y-%m-%d').date()
if data.get('due_date'):
due_date = datetime.strptime(data.get('due_date'), '%Y-%m-%d').date()
# Generate task number
task_number = Task.generate_task_number(g.user.company_id)
# Create task
task = Task(
task_number=task_number,
name=name,
description=data.get('description', ''),
status=TaskStatus[data.get('status', 'TODO')],
priority=TaskPriority[data.get('priority', 'MEDIUM')],
estimated_hours=float(data.get('estimated_hours')) if data.get('estimated_hours') else None,
project_id=project_id,
assigned_to_id=int(data.get('assigned_to_id')) if data.get('assigned_to_id') else None,
sprint_id=int(data.get('sprint_id')) if data.get('sprint_id') else None,
start_date=start_date,
due_date=due_date,
created_by_id=g.user.id
)
db.session.add(task)
db.session.commit()
return jsonify({
'success': True,
'message': 'Task created successfully',
'task': {
'id': task.id,
'task_number': task.task_number
}
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
@tasks_api_bp.route('/tasks/<int:task_id>', methods=['GET'])
@role_required(Role.TEAM_MEMBER)
@company_required
def get_task(task_id):
try:
task = Task.query.join(Project).filter(
Task.id == task_id,
Project.company_id == g.user.company_id
).first()
if not task or not task.can_user_access(g.user):
return jsonify({'success': False, 'message': 'Task not found or access denied'})
task_data = {
'id': task.id,
'task_number': getattr(task, 'task_number', f'TSK-{task.id:03d}'),
'name': task.name,
'description': task.description,
'status': task.status.name,
'priority': task.priority.name,
'estimated_hours': task.estimated_hours,
'assigned_to_id': task.assigned_to_id,
'assigned_to_name': task.assigned_to.username if task.assigned_to else None,
'project_id': task.project_id,
'project_name': task.project.name if task.project else None,
'project_code': task.project.code if task.project else None,
'start_date': task.start_date.isoformat() if task.start_date else None,
'due_date': task.due_date.isoformat() if task.due_date else None,
'completed_date': task.completed_date.isoformat() if task.completed_date else None,
'archived_date': task.archived_date.isoformat() if task.archived_date else None,
'sprint_id': task.sprint_id,
'subtasks': [{
'id': subtask.id,
'name': subtask.name,
'status': subtask.status.name,
'priority': subtask.priority.name,
'assigned_to_id': subtask.assigned_to_id,
'assigned_to_name': subtask.assigned_to.username if subtask.assigned_to else None
} for subtask in task.subtasks] if task.subtasks else []
}
return jsonify(task_data)
except Exception as e:
return jsonify({'success': False, 'message': str(e)})
@tasks_api_bp.route('/tasks/<int:task_id>', methods=['PUT'])
@role_required(Role.TEAM_MEMBER)
@company_required
def update_task(task_id):
try:
task = Task.query.join(Project).filter(
Task.id == task_id,
Project.company_id == g.user.company_id
).first()
if not task or not task.can_user_access(g.user):
return jsonify({'success': False, 'message': 'Task not found or access denied'})
data = request.get_json()
# Update task fields
if 'name' in data:
task.name = data['name']
if 'description' in data:
task.description = data['description']
if 'status' in data:
task.status = TaskStatus[data['status']]
if data['status'] == 'COMPLETED':
task.completed_date = datetime.now().date()
else:
task.completed_date = None
if 'priority' in data:
task.priority = TaskPriority[data['priority']]
if 'estimated_hours' in data:
task.estimated_hours = float(data['estimated_hours']) if data['estimated_hours'] else None
if 'assigned_to_id' in data:
task.assigned_to_id = int(data['assigned_to_id']) if data['assigned_to_id'] else None
if 'sprint_id' in data:
task.sprint_id = int(data['sprint_id']) if data['sprint_id'] else None
if 'start_date' in data:
task.start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date() if data['start_date'] else None
if 'due_date' in data:
task.due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date() if data['due_date'] else None
db.session.commit()
return jsonify({'success': True, 'message': 'Task updated successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
@tasks_api_bp.route('/tasks/<int:task_id>', methods=['DELETE'])
@role_required(Role.TEAM_LEADER) # Only team leaders and above can delete tasks
@company_required
def delete_task(task_id):
try:
task = Task.query.join(Project).filter(
Task.id == task_id,
Project.company_id == g.user.company_id
).first()
if not task or not task.can_user_access(g.user):
return jsonify({'success': False, 'message': 'Task not found or access denied'})
db.session.delete(task)
db.session.commit()
return jsonify({'success': True, 'message': 'Task deleted successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
@tasks_api_bp.route('/tasks/unified')
@role_required(Role.TEAM_MEMBER)
@company_required
def get_unified_tasks():
"""Get all tasks for unified task view"""
try:
# Base query for tasks in user's company
query = Task.query.join(Project).filter(Project.company_id == g.user.company_id)
# Apply access restrictions based on user role and team
if g.user.role not in [Role.ADMIN, Role.SUPERVISOR]:
# Regular users can only see tasks from projects they have access to
accessible_project_ids = []
projects = Project.query.filter_by(company_id=g.user.company_id).all()
for project in projects:
if project.is_user_allowed(g.user):
accessible_project_ids.append(project.id)
if accessible_project_ids:
query = query.filter(Task.project_id.in_(accessible_project_ids))
else:
# No accessible projects, return empty list
return jsonify({'success': True, 'tasks': []})
tasks = query.order_by(Task.created_at.desc()).all()
task_list = []
for task in tasks:
# Determine if this is a team task
is_team_task = (
g.user.team_id and
task.project and
task.project.team_id == g.user.team_id
)
task_data = {
'id': task.id,
'task_number': getattr(task, 'task_number', f'TSK-{task.id:03d}'), # Fallback for existing tasks
'name': task.name,
'description': task.description,
'status': task.status.name,
'priority': task.priority.name,
'estimated_hours': task.estimated_hours,
'project_id': task.project_id,
'project_name': task.project.name if task.project else None,
'project_code': task.project.code if task.project else None,
'assigned_to_id': task.assigned_to_id,
'assigned_to_name': task.assigned_to.username if task.assigned_to else None,
'created_by_id': task.created_by_id,
'created_by_name': task.created_by.username if task.created_by else None,
'start_date': task.start_date.isoformat() if task.start_date else None,
'due_date': task.due_date.isoformat() if task.due_date else None,
'completed_date': task.completed_date.isoformat() if task.completed_date else None,
'created_at': task.created_at.isoformat(),
'is_team_task': is_team_task,
'subtask_count': len(task.subtasks) if task.subtasks else 0,
'subtasks': [{
'id': subtask.id,
'name': subtask.name,
'status': subtask.status.name,
'priority': subtask.priority.name,
'assigned_to_id': subtask.assigned_to_id,
'assigned_to_name': subtask.assigned_to.username if subtask.assigned_to else None
} for subtask in task.subtasks] if task.subtasks else [],
'sprint_id': task.sprint_id,
'sprint_name': task.sprint.name if task.sprint else None,
'is_current_sprint': task.sprint.is_current if task.sprint else False
}
task_list.append(task_data)
return jsonify({'success': True, 'tasks': task_list})
except Exception as e:
logger.error(f"Error in get_unified_tasks: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
@tasks_api_bp.route('/tasks/<int:task_id>/status', methods=['PUT'])
@role_required(Role.TEAM_MEMBER)
@company_required
def update_task_status(task_id):
"""Update task status"""
try:
task = Task.query.join(Project).filter(
Task.id == task_id,
Project.company_id == g.user.company_id
).first()
if not task or not task.can_user_access(g.user):
return jsonify({'success': False, 'message': 'Task not found or access denied'})
data = request.get_json()
new_status = data.get('status')
if not new_status:
return jsonify({'success': False, 'message': 'Status is required'})
# Validate status value - convert from enum name to enum object
try:
task_status = TaskStatus[new_status]
except KeyError:
return jsonify({'success': False, 'message': 'Invalid status value'})
# Update task status
old_status = task.status
task.status = task_status
# Set completion date if status is DONE
if task_status == TaskStatus.DONE:
task.completed_date = datetime.now().date()
elif old_status == TaskStatus.DONE:
# Clear completion date if moving away from done
task.completed_date = None
# Set archived date if status is ARCHIVED
if task_status == TaskStatus.ARCHIVED:
task.archived_date = datetime.now().date()
elif old_status == TaskStatus.ARCHIVED:
# Clear archived date if moving away from archived
task.archived_date = None
db.session.commit()
return jsonify({
'success': True,
'message': 'Task status updated successfully',
'old_status': old_status.name,
'new_status': task_status.name
})
except Exception as e:
db.session.rollback()
logger.error(f"Error updating task status: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
# Task Dependencies APIs
@tasks_api_bp.route('/tasks/<int:task_id>/dependencies')
@role_required(Role.TEAM_MEMBER)
@company_required
def get_task_dependencies(task_id):
"""Get dependencies for a specific task"""
try:
# Get the task and verify ownership through project
task = Task.query.join(Project).filter(
Task.id == task_id,
Project.company_id == g.user.company_id
).first()
if not task:
return jsonify({'success': False, 'message': 'Task not found'})
# Get blocked by dependencies (tasks that block this one)
blocked_by_query = db.session.query(Task).join(
TaskDependency, Task.id == TaskDependency.blocking_task_id
).filter(TaskDependency.blocked_task_id == task_id)
# Get blocks dependencies (tasks that this one blocks)
blocks_query = db.session.query(Task).join(
TaskDependency, Task.id == TaskDependency.blocked_task_id
).filter(TaskDependency.blocking_task_id == task_id)
blocked_by_tasks = blocked_by_query.all()
blocks_tasks = blocks_query.all()
def task_to_dict(t):
return {
'id': t.id,
'name': t.name,
'task_number': t.task_number
}
return jsonify({
'success': True,
'dependencies': {
'blocked_by': [task_to_dict(t) for t in blocked_by_tasks],
'blocks': [task_to_dict(t) for t in blocks_tasks]
}
})
except Exception as e:
logger.error(f"Error getting task dependencies: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
@tasks_api_bp.route('/tasks/<int:task_id>/dependencies', methods=['POST'])
@role_required(Role.TEAM_MEMBER)
@company_required
def add_task_dependency(task_id):
"""Add a dependency for a task"""
try:
data = request.get_json()
task_number = data.get('task_number')
dependency_type = data.get('type') # 'blocked_by' or 'blocks'
if not task_number or not dependency_type:
return jsonify({'success': False, 'message': 'Task number and type are required'})
# Get the main task
task = Task.query.join(Project).filter(
Task.id == task_id,
Project.company_id == g.user.company_id
).first()
if not task:
return jsonify({'success': False, 'message': 'Task not found'})
# Find the dependency task by task number
dependency_task = Task.query.join(Project).filter(
Task.task_number == task_number,
Project.company_id == g.user.company_id
).first()
if not dependency_task:
return jsonify({'success': False, 'message': f'Task {task_number} not found'})
# Prevent self-dependency
if dependency_task.id == task_id:
return jsonify({'success': False, 'message': 'A task cannot depend on itself'})
# Create the dependency based on type
if dependency_type == 'blocked_by':
# Current task is blocked by the dependency task
blocked_task_id = task_id
blocking_task_id = dependency_task.id
elif dependency_type == 'blocks':
# Current task blocks the dependency task
blocked_task_id = dependency_task.id
blocking_task_id = task_id
else:
return jsonify({'success': False, 'message': 'Invalid dependency type'})
# Check if dependency already exists
existing_dep = TaskDependency.query.filter_by(
blocked_task_id=blocked_task_id,
blocking_task_id=blocking_task_id
).first()
if existing_dep:
return jsonify({'success': False, 'message': 'This dependency already exists'})
# Check for circular dependencies
def would_create_cycle(blocked_id, blocking_id):
# Use a simple DFS to check if adding this dependency would create a cycle
visited = set()
def dfs(current_blocked_id):
if current_blocked_id in visited:
return False
visited.add(current_blocked_id)
# If we reach the original blocking task, we have a cycle
if current_blocked_id == blocking_id:
return True
# Check all tasks that block the current task
dependencies = TaskDependency.query.filter_by(blocked_task_id=current_blocked_id).all()
for dep in dependencies:
if dfs(dep.blocking_task_id):
return True
return False
return dfs(blocked_id)
if would_create_cycle(blocked_task_id, blocking_task_id):
return jsonify({'success': False, 'message': 'This dependency would create a circular dependency'})
# Create the new dependency
new_dependency = TaskDependency(
blocked_task_id=blocked_task_id,
blocking_task_id=blocking_task_id
)
db.session.add(new_dependency)
db.session.commit()
return jsonify({'success': True, 'message': 'Dependency added successfully'})
except Exception as e:
db.session.rollback()
logger.error(f"Error adding task dependency: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
@tasks_api_bp.route('/tasks/<int:task_id>/dependencies/<int:dependency_task_id>', methods=['DELETE'])
@role_required(Role.TEAM_MEMBER)
@company_required
def remove_task_dependency(task_id, dependency_task_id):
"""Remove a dependency for a task"""
try:
data = request.get_json()
dependency_type = data.get('type') # 'blocked_by' or 'blocks'
if not dependency_type:
return jsonify({'success': False, 'message': 'Dependency type is required'})
# Get the main task
task = Task.query.join(Project).filter(
Task.id == task_id,
Project.company_id == g.user.company_id
).first()
if not task:
return jsonify({'success': False, 'message': 'Task not found'})
# Determine which dependency to remove based on type
if dependency_type == 'blocked_by':
# Remove dependency where current task is blocked by dependency_task_id
dependency = TaskDependency.query.filter_by(
blocked_task_id=task_id,
blocking_task_id=dependency_task_id
).first()
elif dependency_type == 'blocks':
# Remove dependency where current task blocks dependency_task_id
dependency = TaskDependency.query.filter_by(
blocked_task_id=dependency_task_id,
blocking_task_id=task_id
).first()
else:
return jsonify({'success': False, 'message': 'Invalid dependency type'})
if not dependency:
return jsonify({'success': False, 'message': 'Dependency not found'})
db.session.delete(dependency)
db.session.commit()
return jsonify({'success': True, 'message': 'Dependency removed successfully'})
except Exception as e:
db.session.rollback()
logger.error(f"Error removing task dependency: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
# Task Archive/Restore APIs
@tasks_api_bp.route('/tasks/<int:task_id>/archive', methods=['POST'])
@role_required(Role.TEAM_MEMBER)
@company_required
def archive_task(task_id):
"""Archive a completed task"""
try:
# Get the task and verify ownership through project
task = Task.query.join(Project).filter(
Task.id == task_id,
Project.company_id == g.user.company_id
).first()
if not task:
return jsonify({'success': False, 'message': 'Task not found'})
# Only allow archiving completed tasks
if task.status != TaskStatus.COMPLETED:
return jsonify({'success': False, 'message': 'Only completed tasks can be archived'})
# Archive the task
task.status = TaskStatus.ARCHIVED
task.archived_date = datetime.now().date()
db.session.commit()
return jsonify({
'success': True,
'message': 'Task archived successfully',
'archived_date': task.archived_date.isoformat()
})
except Exception as e:
db.session.rollback()
logger.error(f"Error archiving task: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
@tasks_api_bp.route('/tasks/<int:task_id>/restore', methods=['POST'])
@role_required(Role.TEAM_MEMBER)
@company_required
def restore_task(task_id):
"""Restore an archived task to completed status"""
try:
# Get the task and verify ownership through project
task = Task.query.join(Project).filter(
Task.id == task_id,
Project.company_id == g.user.company_id
).first()
if not task:
return jsonify({'success': False, 'message': 'Task not found'})
# Only allow restoring archived tasks
if task.status != TaskStatus.ARCHIVED:
return jsonify({'success': False, 'message': 'Only archived tasks can be restored'})
# Restore the task to completed status
task.status = TaskStatus.COMPLETED
task.archived_date = None
db.session.commit()
return jsonify({
'success': True,
'message': 'Task restored successfully'
})
except Exception as e:
db.session.rollback()
logger.error(f"Error restoring task: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
# Subtask API Routes
@tasks_api_bp.route('/subtasks', methods=['POST'])
@role_required(Role.TEAM_MEMBER)
@company_required
def create_subtask():
try:
data = request.get_json()
task_id = data.get('task_id')
# Verify task access
task = Task.query.join(Project).filter(
Task.id == task_id,
Project.company_id == g.user.company_id
).first()
if not task or not task.can_user_access(g.user):
return jsonify({'success': False, 'message': 'Task not found or access denied'})
# Validate required fields
name = data.get('name')
if not name:
return jsonify({'success': False, 'message': 'Subtask name is required'})
# Parse dates
start_date = None
due_date = None
if data.get('start_date'):
start_date = datetime.strptime(data.get('start_date'), '%Y-%m-%d').date()
if data.get('due_date'):
due_date = datetime.strptime(data.get('due_date'), '%Y-%m-%d').date()
# Create subtask
subtask = SubTask(
name=name,
description=data.get('description', ''),
status=TaskStatus[data.get('status', 'TODO')],
priority=TaskPriority[data.get('priority', 'MEDIUM')],
estimated_hours=float(data.get('estimated_hours')) if data.get('estimated_hours') else None,
task_id=task_id,
assigned_to_id=int(data.get('assigned_to_id')) if data.get('assigned_to_id') else None,
start_date=start_date,
due_date=due_date,
created_by_id=g.user.id
)
db.session.add(subtask)
db.session.commit()
return jsonify({'success': True, 'message': 'Subtask created successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
@tasks_api_bp.route('/subtasks/<int:subtask_id>', methods=['GET'])
@role_required(Role.TEAM_MEMBER)
@company_required
def get_subtask(subtask_id):
try:
subtask = SubTask.query.join(Task).join(Project).filter(
SubTask.id == subtask_id,
Project.company_id == g.user.company_id
).first()
if not subtask or not subtask.can_user_access(g.user):
return jsonify({'success': False, 'message': 'Subtask not found or access denied'})
subtask_data = {
'id': subtask.id,
'name': subtask.name,
'description': subtask.description,
'status': subtask.status.name,
'priority': subtask.priority.name,
'estimated_hours': subtask.estimated_hours,
'assigned_to_id': subtask.assigned_to_id,
'start_date': subtask.start_date.isoformat() if subtask.start_date else None,
'due_date': subtask.due_date.isoformat() if subtask.due_date else None
}
return jsonify({'success': True, 'subtask': subtask_data})
except Exception as e:
return jsonify({'success': False, 'message': str(e)})
@tasks_api_bp.route('/subtasks/<int:subtask_id>', methods=['PUT'])
@role_required(Role.TEAM_MEMBER)
@company_required
def update_subtask(subtask_id):
try:
subtask = SubTask.query.join(Task).join(Project).filter(
SubTask.id == subtask_id,
Project.company_id == g.user.company_id
).first()
if not subtask or not subtask.can_user_access(g.user):
return jsonify({'success': False, 'message': 'Subtask not found or access denied'})
data = request.get_json()
# Update subtask fields
if 'name' in data:
subtask.name = data['name']
if 'description' in data:
subtask.description = data['description']
if 'status' in data:
subtask.status = TaskStatus[data['status']]
if data['status'] == 'COMPLETED':
subtask.completed_date = datetime.now().date()
else:
subtask.completed_date = None
if 'priority' in data:
subtask.priority = TaskPriority[data['priority']]
if 'estimated_hours' in data:
subtask.estimated_hours = float(data['estimated_hours']) if data['estimated_hours'] else None
if 'assigned_to_id' in data:
subtask.assigned_to_id = int(data['assigned_to_id']) if data['assigned_to_id'] else None
if 'start_date' in data:
subtask.start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date() if data['start_date'] else None
if 'due_date' in data:
subtask.due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date() if data['due_date'] else None
db.session.commit()
return jsonify({'success': True, 'message': 'Subtask updated successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
@tasks_api_bp.route('/subtasks/<int:subtask_id>', methods=['DELETE'])
@role_required(Role.TEAM_LEADER) # Only team leaders and above can delete subtasks
@company_required
def delete_subtask(subtask_id):
try:
subtask = SubTask.query.join(Task).join(Project).filter(
SubTask.id == subtask_id,
Project.company_id == g.user.company_id
).first()
if not subtask or not subtask.can_user_access(g.user):
return jsonify({'success': False, 'message': 'Subtask not found or access denied'})
db.session.delete(subtask)
db.session.commit()
return jsonify({'success': True, 'message': 'Subtask deleted successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)})
# Comment API Routes
@tasks_api_bp.route('/tasks/<int:task_id>/comments', methods=['GET', 'POST'])
@login_required
@company_required
def handle_task_comments(task_id):
"""Handle GET and POST requests for task comments"""
if request.method == 'GET':
return get_task_comments(task_id)
else: # POST
return create_task_comment(task_id)
@tasks_api_bp.route('/comments/<int:comment_id>', methods=['PUT', 'DELETE'])
@login_required
@company_required
def handle_comment(comment_id):
"""Handle PUT and DELETE requests for a specific comment"""
if request.method == 'DELETE':
return delete_comment(comment_id)
else: # PUT
return update_comment(comment_id)
def delete_comment(comment_id):
"""Delete a comment"""
try:
comment = Comment.query.join(Task).join(Project).filter(
Comment.id == comment_id,
Project.company_id == g.user.company_id
).first()
if not comment:
return jsonify({'success': False, 'message': 'Comment not found'}), 404
# Check if user can delete this comment
if not comment.can_user_delete(g.user):
return jsonify({'success': False, 'message': 'You do not have permission to delete this comment'}), 403
# Delete the comment (replies will be cascade deleted)
db.session.delete(comment)
db.session.commit()
return jsonify({'success': True, 'message': 'Comment deleted successfully'})
except Exception as e:
db.session.rollback()
logger.error(f"Error deleting comment {comment_id}: {e}")
return jsonify({'success': False, 'message': 'Failed to delete comment'}), 500
def update_comment(comment_id):
"""Update a comment"""
try:
comment = Comment.query.join(Task).join(Project).filter(
Comment.id == comment_id,
Project.company_id == g.user.company_id
).first()
if not comment:
return jsonify({'success': False, 'message': 'Comment not found'}), 404
# Check if user can edit this comment
if not comment.can_user_edit(g.user):
return jsonify({'success': False, 'message': 'You do not have permission to edit this comment'}), 403
data = request.json
new_content = data.get('content', '').strip()
if not new_content:
return jsonify({'success': False, 'message': 'Comment content is required'}), 400
# Update the comment
comment.content = new_content
comment.is_edited = True
comment.edited_at = datetime.now()
db.session.commit()
return jsonify({
'success': True,
'message': 'Comment updated successfully',
'comment': {
'id': comment.id,
'content': comment.content,
'is_edited': comment.is_edited,
'edited_at': comment.edited_at.isoformat()
}
})
except Exception as e:
db.session.rollback()
logger.error(f"Error updating comment {comment_id}: {e}")
return jsonify({'success': False, 'message': 'Failed to update comment'}), 500
def get_task_comments(task_id):
"""Get all comments for a task that the user can view"""
try:
task = Task.query.join(Project).filter(
Task.id == task_id,
Project.company_id == g.user.company_id
).first()
if not task or not task.can_user_access(g.user):
return jsonify({'success': False, 'message': 'Task not found or access denied'})
# Get all comments for the task
comments = []
for comment in task.comments.order_by(Comment.created_at.desc()):
if comment.can_user_view(g.user):
comment_data = {
'id': comment.id,
'content': comment.content,
'visibility': comment.visibility.value,
'is_edited': comment.is_edited,
'edited_at': comment.edited_at.isoformat() if comment.edited_at else None,
'created_at': comment.created_at.isoformat(),
'author': {
'id': comment.created_by.id,
'username': comment.created_by.username,
'avatar_url': comment.created_by.get_avatar_url(40)
},
'can_edit': comment.can_user_edit(g.user),
'can_delete': comment.can_user_delete(g.user),
'replies': []
}
# Add replies if any
for reply in comment.replies:
if reply.can_user_view(g.user):
reply_data = {
'id': reply.id,
'content': reply.content,
'is_edited': reply.is_edited,
'edited_at': reply.edited_at.isoformat() if reply.edited_at else None,
'created_at': reply.created_at.isoformat(),
'author': {
'id': reply.created_by.id,
'username': reply.created_by.username,
'avatar_url': reply.created_by.get_avatar_url(40)
},
'can_edit': reply.can_user_edit(g.user),
'can_delete': reply.can_user_delete(g.user)
}
comment_data['replies'].append(reply_data)
comments.append(comment_data)
# Check if user can use team visibility
company_settings = CompanySettings.query.filter_by(company_id=g.user.company_id).first()
allow_team_visibility = company_settings.allow_team_visibility_comments if company_settings else True
return jsonify({
'success': True,
'comments': comments,
'allow_team_visibility': allow_team_visibility
})
except Exception as e:
return jsonify({'success': False, 'message': str(e)})
def create_task_comment(task_id):
"""Create a new comment on a task"""
try:
# Get the task and verify access through project
task = Task.query.join(Project).filter(
Task.id == task_id,
Project.company_id == g.user.company_id
).first()
if not task:
return jsonify({'success': False, 'message': 'Task not found'})
# Check if user has access to this task's project
if not task.project.is_user_allowed(g.user):
return jsonify({'success': False, 'message': 'Access denied'})
data = request.json
content = data.get('content', '').strip()
visibility = data.get('visibility', CommentVisibility.COMPANY.value)
if not content:
return jsonify({'success': False, 'message': 'Comment content is required'})
# Validate visibility - handle case conversion
try:
# Convert from frontend format (TEAM/COMPANY) to enum format (Team/Company)
if visibility == 'TEAM':
visibility_enum = CommentVisibility.TEAM
elif visibility == 'COMPANY':
visibility_enum = CommentVisibility.COMPANY
else:
# Try to use the value directly in case it's already in the right format
visibility_enum = CommentVisibility(visibility)
except ValueError:
return jsonify({'success': False, 'message': f'Invalid visibility option: {visibility}'})
# Create the comment
comment = Comment(
content=content,
task_id=task_id,
created_by_id=g.user.id,
visibility=visibility_enum
)
db.session.add(comment)
db.session.commit()
return jsonify({
'success': True,
'message': 'Comment added successfully',
'comment': {
'id': comment.id,
'content': comment.content,
'visibility': comment.visibility.value,
'created_at': comment.created_at.isoformat(),
'user': {
'id': comment.created_by.id,
'username': comment.created_by.username
}
}
})
except Exception as e:
db.session.rollback()
logger.error(f"Error creating task comment: {str(e)}")
return jsonify({'success': False, 'message': str(e)})

191
routes/teams.py Normal file
View File

@@ -0,0 +1,191 @@
"""
Team Management Routes
Handles all team-related views and operations
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, g, abort
from models import db, Team, User
from routes.auth import admin_required, company_required
from utils.validation import FormValidator
from utils.repository import TeamRepository
teams_bp = Blueprint('teams', __name__, url_prefix='/admin/teams')
@teams_bp.route('')
@admin_required
@company_required
def admin_teams():
team_repo = TeamRepository()
teams = team_repo.get_with_member_count(g.user.company_id)
return render_template('admin_teams.html', title='Team Management', teams=teams)
@teams_bp.route('/create', methods=['GET', 'POST'])
@admin_required
@company_required
def create_team():
if request.method == 'POST':
validator = FormValidator()
team_repo = TeamRepository()
name = request.form.get('name')
description = request.form.get('description')
# Validate input
validator.validate_required(name, 'Team name')
if validator.is_valid() and team_repo.exists_by_name_in_company(name, g.user.company_id):
validator.errors.add('Team name already exists in your company')
if validator.is_valid():
new_team = team_repo.create(
name=name,
description=description,
company_id=g.user.company_id
)
team_repo.save()
flash(f'Team "{name}" created successfully!', 'success')
return redirect(url_for('teams.admin_teams'))
validator.flash_errors()
return render_template('team_form.html', title='Create Team', team=None)
@teams_bp.route('/edit/<int:team_id>', methods=['GET', 'POST'])
@admin_required
@company_required
def edit_team(team_id):
team_repo = TeamRepository()
team = team_repo.get_by_id_and_company(team_id, g.user.company_id)
if not team:
abort(404)
if request.method == 'POST':
validator = FormValidator()
name = request.form.get('name')
description = request.form.get('description')
# Validate input
validator.validate_required(name, 'Team name')
if validator.is_valid() and name != team.name:
if team_repo.exists_by_name_in_company(name, g.user.company_id):
validator.errors.add('Team name already exists in your company')
if validator.is_valid():
team_repo.update(team, name=name, description=description)
team_repo.save()
flash(f'Team "{name}" updated successfully!', 'success')
return redirect(url_for('teams.admin_teams'))
validator.flash_errors()
return render_template('edit_team.html', title='Edit Team', team=team)
@teams_bp.route('/delete/<int:team_id>', methods=['POST'])
@admin_required
@company_required
def delete_team(team_id):
team_repo = TeamRepository()
team = team_repo.get_by_id_and_company(team_id, g.user.company_id)
if not team:
abort(404)
# Check if team has members
if team.users:
flash('Cannot delete team with members. Remove all members first.', 'error')
return redirect(url_for('teams.admin_teams'))
team_name = team.name
team_repo.delete(team)
team_repo.save()
flash(f'Team "{team_name}" deleted successfully!', 'success')
return redirect(url_for('teams.admin_teams'))
@teams_bp.route('/<int:team_id>', methods=['GET', 'POST'])
@admin_required
@company_required
def manage_team(team_id):
team_repo = TeamRepository()
team = team_repo.get_by_id_and_company(team_id, g.user.company_id)
if not team:
abort(404)
if request.method == 'POST':
action = request.form.get('action')
if action == 'update_team':
# Update team details
validator = FormValidator()
name = request.form.get('name')
description = request.form.get('description')
# Validate input
validator.validate_required(name, 'Team name')
if validator.is_valid() and name != team.name:
if team_repo.exists_by_name_in_company(name, g.user.company_id):
validator.errors.add('Team name already exists in your company')
if validator.is_valid():
team_repo.update(team, name=name, description=description)
team_repo.save()
flash(f'Team "{name}" updated successfully!', 'success')
else:
validator.flash_errors()
elif action == 'add_member':
# Add user to team
user_id = request.form.get('user_id')
if user_id:
user = User.query.get(user_id)
if user:
user.team_id = team.id
db.session.commit()
flash(f'User {user.username} added to team!', 'success')
else:
flash('User not found', 'error')
else:
flash('No user selected', 'error')
elif action == 'remove_member':
# Remove user from team
user_id = request.form.get('user_id')
if user_id:
user = User.query.get(user_id)
if user and user.team_id == team.id:
user.team_id = None
db.session.commit()
flash(f'User {user.username} removed from team!', 'success')
else:
flash('User not found or not in this team', 'error')
else:
flash('No user selected', 'error')
# Get team members
team_members = User.query.filter_by(team_id=team.id).all()
# Get users not in this team for the add member form (company-scoped)
available_users = User.query.filter(
User.company_id == g.user.company_id,
(User.team_id != team.id) | (User.team_id == None)
).all()
return render_template(
'team_form.html',
title=f'Manage Team: {team.name}',
team=team,
team_members=team_members,
available_users=available_users
)

124
routes/teams_api.py Normal file
View File

@@ -0,0 +1,124 @@
"""
Team Management API Routes
Handles all team-related API endpoints
"""
from flask import Blueprint, jsonify, request, g
from datetime import datetime, time, timedelta
from models import Team, User, TimeEntry, Role
from routes.auth import login_required, role_required, company_required, system_admin_required
teams_api_bp = Blueprint('teams_api', __name__, url_prefix='/api')
@teams_api_bp.route('/team/hours_data', methods=['GET'])
@login_required
@role_required(Role.TEAM_LEADER) # Only team leaders and above can access
@company_required
def team_hours_data():
# Get the current user's team
team = Team.query.get(g.user.team_id)
if not team:
return jsonify({
'success': False,
'message': 'You are not assigned to any team.'
}), 400
# Get date range from query parameters or use current week as default
today = datetime.now().date()
start_of_week = today - timedelta(days=today.weekday())
end_of_week = start_of_week + timedelta(days=6)
start_date_str = request.args.get('start_date', start_of_week.strftime('%Y-%m-%d'))
end_date_str = request.args.get('end_date', end_of_week.strftime('%Y-%m-%d'))
include_self = request.args.get('include_self', 'false') == 'true'
try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
except ValueError:
return jsonify({
'success': False,
'message': 'Invalid date format.'
}), 400
# Get all team members
team_members = User.query.filter_by(team_id=team.id).all()
# Prepare data structure for team members' hours
team_data = []
for member in team_members:
# Skip if the member is the current user (team leader) and include_self is False
if member.id == g.user.id and not include_self:
continue
# Get time entries for this member in the date range
entries = TimeEntry.query.filter(
TimeEntry.user_id == member.id,
TimeEntry.arrival_time >= datetime.combine(start_date, time.min),
TimeEntry.arrival_time <= datetime.combine(end_date, time.max)
).order_by(TimeEntry.arrival_time).all()
# Calculate daily and total hours
daily_hours = {}
total_seconds = 0
for entry in entries:
if entry.duration: # Only count completed entries
entry_date = entry.arrival_time.date()
date_str = entry_date.strftime('%Y-%m-%d')
if date_str not in daily_hours:
daily_hours[date_str] = 0
daily_hours[date_str] += entry.duration
total_seconds += entry.duration
# Convert seconds to hours for display
for date_str in daily_hours:
daily_hours[date_str] = round(daily_hours[date_str] / 3600, 2) # Convert to hours
total_hours = round(total_seconds / 3600, 2) # Convert to hours
# Format entries for JSON response
formatted_entries = []
for entry in entries:
formatted_entries.append({
'id': entry.id,
'arrival_time': entry.arrival_time.isoformat(),
'departure_time': entry.departure_time.isoformat() if entry.departure_time else None,
'duration': entry.duration,
'total_break_duration': entry.total_break_duration
})
# Add member data to team data
team_data.append({
'member_id': member.id,
'member_name': member.username,
'daily_hours': daily_hours,
'total_hours': total_hours,
'entries': formatted_entries
})
return jsonify({
'success': True,
'team_name': team.name,
'team_id': team.id,
'start_date': start_date_str,
'end_date': end_date_str,
'team_data': team_data
})
@teams_api_bp.route('/companies/<int:company_id>/teams')
@system_admin_required
def api_company_teams(company_id):
"""API: Get teams for a specific company (System Admin only)"""
teams = Team.query.filter_by(company_id=company_id).order_by(Team.name).all()
return jsonify([{
'id': team.id,
'name': team.name,
'description': team.description
} for team in teams])

532
routes/users.py Normal file
View File

@@ -0,0 +1,532 @@
"""
User management routes
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, g, session, abort
from models import db, User, Role, Team, TimeEntry, WorkConfig, UserPreferences, Project, Task, SubTask, ProjectCategory, UserDashboard, Comment, Company
from routes.auth import admin_required, company_required, login_required, system_admin_required
from flask_mail import Message
from flask import current_app
from utils.validation import FormValidator
from utils.repository import UserRepository
import logging
logger = logging.getLogger(__name__)
users_bp = Blueprint('users', __name__, url_prefix='/admin/users')
def get_available_roles():
"""Get roles available for user creation/editing based on current user's role"""
current_user_role = g.user.role
if current_user_role == Role.SYSTEM_ADMIN:
# System admin can assign any role
return [Role.TEAM_MEMBER, Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN, Role.SYSTEM_ADMIN]
elif current_user_role == Role.ADMIN:
# Admin can assign any role except system admin
return [Role.TEAM_MEMBER, Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN]
elif current_user_role == Role.SUPERVISOR:
# Supervisor can only assign team member and team leader roles
return [Role.TEAM_MEMBER, Role.TEAM_LEADER]
else:
# Others cannot assign roles
return []
@users_bp.route('')
@admin_required
@company_required
def admin_users():
user_repo = UserRepository()
users = user_repo.get_by_company(g.user.company_id)
return render_template('admin_users.html', title='User Management', users=users)
@users_bp.route('/create', methods=['GET', 'POST'])
@admin_required
@company_required
def create_user():
if request.method == 'POST':
validator = FormValidator()
user_repo = UserRepository()
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
auto_verify = 'auto_verify' in request.form
# Get role and team
role_name = request.form.get('role')
team_id = request.form.get('team_id')
# Validate required fields
validator.validate_required(username, 'Username')
validator.validate_required(email, 'Email')
validator.validate_required(password, 'Password')
# Validate uniqueness
if validator.is_valid():
validator.validate_unique(User, 'username', username, company_id=g.user.company_id)
validator.validate_unique(User, 'email', email, company_id=g.user.company_id)
if validator.is_valid():
# Convert role string to enum
try:
role = Role[role_name] if role_name else Role.TEAM_MEMBER
except KeyError:
role = Role.TEAM_MEMBER
# Create new user with role and team
new_user = user_repo.create(
username=username,
email=email,
company_id=g.user.company_id,
is_verified=auto_verify,
role=role,
team_id=team_id if team_id else None
)
new_user.set_password(password)
if not auto_verify:
# Generate verification token and send email
token = new_user.generate_verification_token()
verification_url = url_for('verify_email', token=token, _external=True)
try:
from flask_mail import Mail
mail = Mail(current_app)
# Get branding for email
from models import BrandingSettings
branding = BrandingSettings.get_settings()
msg = Message(
f'Welcome to {branding.app_name} - Verify Your Email',
sender=(branding.app_name, current_app.config['MAIL_USERNAME']),
recipients=[email]
)
msg.body = f'''Welcome to {branding.app_name}!
Your administrator has created an account for you. Please verify your email address to activate your account.
Username: {username}
Company: {g.company.name}
Click the link below to verify your email:
{verification_url}
This link will expire in 24 hours.
If you did not expect this email, please ignore it.
Best regards,
The {branding.app_name} Team
'''
mail.send(msg)
logger.info(f"Verification email sent to {email}")
except Exception as e:
logger.error(f"Failed to send verification email: {str(e)}")
flash('User created but verification email could not be sent. Please contact the user directly.', 'warning')
user_repo.save()
flash(f'User {username} created successfully!', 'success')
return redirect(url_for('users.admin_users'))
validator.flash_errors()
# Get all teams for the form (company-scoped)
teams = Team.query.filter_by(company_id=g.user.company_id).all()
roles = get_available_roles()
return render_template('create_user.html', title='Create User', teams=teams, roles=roles)
@users_bp.route('/edit/<int:user_id>', methods=['GET', 'POST'])
@admin_required
@company_required
def edit_user(user_id):
user_repo = UserRepository()
user = user_repo.get_by_id_and_company(user_id, g.user.company_id)
if not user:
abort(404)
if request.method == 'POST':
validator = FormValidator()
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
# Get role and team
role_name = request.form.get('role')
team_id = request.form.get('team_id')
# Validate required fields
validator.validate_required(username, 'Username')
validator.validate_required(email, 'Email')
# Validate uniqueness (exclude current user)
if validator.is_valid():
if username != user.username:
validator.validate_unique(User, 'username', username, company_id=g.user.company_id)
if email != user.email:
validator.validate_unique(User, 'email', email, company_id=g.user.company_id)
if validator.is_valid():
# Convert role string to enum
try:
role = Role[role_name] if role_name else Role.TEAM_MEMBER
except KeyError:
role = Role.TEAM_MEMBER
user_repo.update(user,
username=username,
email=email,
role=role,
team_id=team_id if team_id else None
)
if password:
user.set_password(password)
user_repo.save()
flash(f'User {username} updated successfully!', 'success')
return redirect(url_for('users.admin_users'))
validator.flash_errors()
# Get all teams for the form (company-scoped)
teams = Team.query.filter_by(company_id=g.user.company_id).all()
roles = get_available_roles()
return render_template('edit_user.html', title='Edit User', user=user, teams=teams, roles=roles)
@users_bp.route('/delete/<int:user_id>', methods=['POST'])
@admin_required
@company_required
def delete_user(user_id):
user = User.query.filter_by(id=user_id, company_id=g.user.company_id).first_or_404()
# Prevent deleting yourself
if user.id == session.get('user_id'):
flash('You cannot delete your own account', 'error')
return redirect(url_for('users.admin_users'))
username = user.username
try:
# Check if user owns any critical resources
owns_projects = Project.query.filter_by(created_by_id=user_id).count() > 0
owns_tasks = Task.query.filter_by(created_by_id=user_id).count() > 0
owns_subtasks = SubTask.query.filter_by(created_by_id=user_id).count() > 0
needs_ownership_transfer = owns_projects or owns_tasks or owns_subtasks
if needs_ownership_transfer:
# Find an alternative admin/supervisor to transfer ownership to
alternative_admin = User.query.filter(
User.company_id == g.user.company_id,
User.role.in_([Role.ADMIN, Role.SUPERVISOR]),
User.id != user_id
).first()
if alternative_admin:
# Transfer ownership of projects to alternative admin
if owns_projects:
Project.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
# Transfer ownership of tasks to alternative admin
if owns_tasks:
Task.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
# Transfer ownership of subtasks to alternative admin
if owns_subtasks:
SubTask.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
else:
# No alternative admin found but user owns resources
flash('Cannot delete this user. They own resources but no other administrator or supervisor found to transfer ownership to.', 'error')
return redirect(url_for('users.admin_users'))
# Delete user-specific records that can be safely removed
TimeEntry.query.filter_by(user_id=user_id).delete()
WorkConfig.query.filter_by(user_id=user_id).delete()
UserPreferences.query.filter_by(user_id=user_id).delete()
# Delete user dashboards (cascades to widgets)
UserDashboard.query.filter_by(user_id=user_id).delete()
# Clear task and subtask assignments
Task.query.filter_by(assigned_to_id=user_id).update({'assigned_to_id': None})
SubTask.query.filter_by(assigned_to_id=user_id).update({'assigned_to_id': None})
# Now safe to delete the user
db.session.delete(user)
db.session.commit()
if needs_ownership_transfer and alternative_admin:
flash(f'User {username} deleted successfully. Projects and tasks transferred to {alternative_admin.username}', 'success')
else:
flash(f'User {username} deleted successfully.', 'success')
except Exception as e:
db.session.rollback()
logger.error(f"Error deleting user {user_id}: {str(e)}")
flash(f'Error deleting user: {str(e)}', 'error')
return redirect(url_for('users.admin_users'))
@users_bp.route('/toggle-status/<int:user_id>', methods=['POST'])
@admin_required
@company_required
def toggle_user_status(user_id):
"""Toggle user active/blocked status"""
user = User.query.filter_by(id=user_id, company_id=g.user.company_id).first_or_404()
# Prevent blocking yourself
if user.id == g.user.id:
flash('You cannot block your own account', 'error')
return redirect(url_for('users.admin_users'))
# Toggle the blocked status
user.is_blocked = not user.is_blocked
db.session.commit()
status = 'blocked' if user.is_blocked else 'unblocked'
flash(f'User {user.username} has been {status}', 'success')
return redirect(url_for('users.admin_users'))
# System Admin User Routes
@users_bp.route('/system-admin')
@system_admin_required
def system_admin_users():
"""System admin view of all users across all companies"""
# Get filter parameters
company_id = request.args.get('company_id', type=int)
search_query = request.args.get('search', '')
filter_type = request.args.get('filter', '')
page = request.args.get('page', 1, type=int)
per_page = 50
# Build query that returns tuples of (User, company_name)
query = db.session.query(User, Company.name).join(Company)
# Apply company filter
if company_id:
query = query.filter(User.company_id == company_id)
# Apply search filter
if search_query:
query = query.filter(
db.or_(
User.username.ilike(f'%{search_query}%'),
User.email.ilike(f'%{search_query}%')
)
)
# Apply type filter
if filter_type == 'system_admins':
query = query.filter(User.role == Role.SYSTEM_ADMIN)
elif filter_type == 'admins':
query = query.filter(User.role == Role.ADMIN)
elif filter_type == 'blocked':
query = query.filter(User.is_blocked == True)
elif filter_type == 'unverified':
query = query.filter(User.is_verified == False)
elif filter_type == 'freelancers':
query = query.filter(Company.is_personal == True)
# Order by company name and username
query = query.order_by(Company.name, User.username)
# Paginate the results
try:
users = query.paginate(page=page, per_page=per_page, error_out=False)
# Debug log
if users.items:
logger.info(f"First item type: {type(users.items[0])}")
logger.info(f"First item: {users.items[0]}")
except Exception as e:
logger.error(f"Error paginating users: {str(e)}")
# Fallback to empty pagination
from flask_sqlalchemy import Pagination
users = Pagination(query=None, page=1, per_page=per_page, total=0, items=[])
# Get all companies for filter dropdown
companies = Company.query.order_by(Company.name).all()
# Calculate statistics
all_users = User.query.all()
stats = {
'total_users': len(all_users),
'verified_users': len([u for u in all_users if u.is_verified]),
'blocked_users': len([u for u in all_users if u.is_blocked]),
'system_admins': len([u for u in all_users if u.role == Role.SYSTEM_ADMIN]),
'company_admins': len([u for u in all_users if u.role == Role.ADMIN]),
}
return render_template('system_admin_users.html',
title='System User Management',
users=users,
companies=companies,
stats=stats,
selected_company_id=company_id,
search_query=search_query,
current_filter=filter_type)
@users_bp.route('/system-admin/<int:user_id>/edit', methods=['GET', 'POST'])
@system_admin_required
def system_admin_edit_user(user_id):
"""System admin edit any user"""
user = User.query.get_or_404(user_id)
if request.method == 'POST':
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
role_name = request.form.get('role')
company_id = request.form.get('company_id', type=int)
team_id = request.form.get('team_id', type=int)
is_verified = 'is_verified' in request.form
is_blocked = 'is_blocked' in request.form
# Validate input
validator = FormValidator()
validator.validate_required(username, 'Username')
validator.validate_required(email, 'Email')
# Validate uniqueness (exclude current user)
if validator.is_valid():
if username != user.username:
validator.validate_unique(User, 'username', username, company_id=user.company_id)
if email != user.email:
validator.validate_unique(User, 'email', email, company_id=user.company_id)
# Prevent removing the last system admin
if validator.is_valid() and user.role == Role.SYSTEM_ADMIN and role_name != 'SYSTEM_ADMIN':
system_admin_count = User.query.filter_by(role=Role.SYSTEM_ADMIN, is_blocked=False).count()
if system_admin_count <= 1:
validator.errors.add('Cannot remove the last system administrator')
if validator.is_valid():
user.username = username
user.email = email
user.is_verified = is_verified
user.is_blocked = is_blocked
# Update company and team
if company_id:
user.company_id = company_id
if team_id:
user.team_id = team_id
else:
user.team_id = None # Clear team if not selected
# Update role
try:
user.role = Role[role_name] if role_name else Role.TEAM_MEMBER
except KeyError:
user.role = Role.TEAM_MEMBER
if password:
user.set_password(password)
db.session.commit()
flash(f'User {username} updated successfully!', 'success')
return redirect(url_for('users.system_admin_users'))
validator.flash_errors()
# Get all companies and teams for the form
companies = Company.query.order_by(Company.name).all()
teams = Team.query.filter_by(company_id=user.company_id).order_by(Team.name).all()
return render_template('system_admin_edit_user.html',
title='Edit User (System Admin)',
user=user,
companies=companies,
teams=teams,
roles=list(Role),
Role=Role)
@users_bp.route('/system-admin/<int:user_id>/delete', methods=['POST'])
@system_admin_required
def system_admin_delete_user(user_id):
"""System admin delete any user"""
user = User.query.get_or_404(user_id)
# Prevent deleting yourself
if user.id == g.user.id:
flash('You cannot delete your own account', 'error')
return redirect(url_for('users.system_admin_users'))
# Prevent deleting the last system admin
if user.role == Role.SYSTEM_ADMIN:
system_admin_count = User.query.filter_by(role=Role.SYSTEM_ADMIN).count()
if system_admin_count <= 1:
flash('Cannot delete the last system administrator', 'error')
return redirect(url_for('users.system_admin_users'))
username = user.username
company_name = user.company.name
try:
# Check if this is the last admin/supervisor in the company
admin_count = User.query.filter(
User.company_id == user.company_id,
User.role.in_([Role.ADMIN, Role.SUPERVISOR]),
User.id != user_id
).count()
if admin_count == 0:
# This is the last admin - need to handle company data
flash(f'User {username} is the last administrator in {company_name}. Company data will need to be handled.', 'warning')
# For now, just prevent deletion
return redirect(url_for('users.system_admin_users'))
# Otherwise proceed with normal deletion
# Delete user-specific records
TimeEntry.query.filter_by(user_id=user_id).delete()
WorkConfig.query.filter_by(user_id=user_id).delete()
UserPreferences.query.filter_by(user_id=user_id).delete()
UserDashboard.query.filter_by(user_id=user_id).delete()
# Clear assignments
Task.query.filter_by(assigned_to_id=user_id).update({'assigned_to_id': None})
SubTask.query.filter_by(assigned_to_id=user_id).update({'assigned_to_id': None})
# Transfer ownership of created items
alternative_admin = User.query.filter(
User.company_id == user.company_id,
User.role.in_([Role.ADMIN, Role.SUPERVISOR]),
User.id != user_id
).first()
if alternative_admin:
Project.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
Task.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
SubTask.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
ProjectCategory.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
# Delete the user
db.session.delete(user)
db.session.commit()
flash(f'User {username} from {company_name} deleted successfully!', 'success')
except Exception as e:
db.session.rollback()
logger.error(f"Error deleting user {user_id}: {str(e)}")
flash(f'Error deleting user: {str(e)}', 'error')
return redirect(url_for('users.system_admin_users'))

75
routes/users_api.py Normal file
View File

@@ -0,0 +1,75 @@
"""
User API endpoints
"""
from flask import Blueprint, jsonify, request, g
from models import db, User, Role
from routes.auth import system_admin_required, role_required
from sqlalchemy import or_
users_api_bp = Blueprint('users_api', __name__, url_prefix='/api')
@users_api_bp.route('/system-admin/users/<int:user_id>/toggle-block', methods=['POST'])
@system_admin_required
def api_toggle_user_block(user_id):
"""API: Toggle user blocked status (System Admin only)"""
user = User.query.get_or_404(user_id)
# Safety check: prevent blocking yourself
if user.id == g.user.id:
return jsonify({'error': 'Cannot block your own account'}), 400
# Safety check: prevent blocking the last system admin
if user.role == Role.SYSTEM_ADMIN and not user.is_blocked:
system_admin_count = User.query.filter_by(role=Role.SYSTEM_ADMIN, is_blocked=False).count()
if system_admin_count <= 1:
return jsonify({'error': 'Cannot block the last system administrator'}), 400
user.is_blocked = not user.is_blocked
db.session.commit()
return jsonify({
'id': user.id,
'username': user.username,
'is_blocked': user.is_blocked,
'message': f'User {"blocked" if user.is_blocked else "unblocked"} successfully'
})
@users_api_bp.route('/search/users')
@role_required(Role.TEAM_MEMBER)
def search_users():
"""Search for users within the company"""
query = request.args.get('q', '').strip()
exclude_id = request.args.get('exclude', type=int)
if not query or len(query) < 2:
return jsonify({'users': []})
# Search users in the same company
users_query = User.query.filter(
User.company_id == g.user.company_id,
or_(
User.username.ilike(f'%{query}%'),
User.email.ilike(f'%{query}%')
),
User.is_blocked == False,
User.is_verified == True
)
if exclude_id:
users_query = users_query.filter(User.id != exclude_id)
users = users_query.limit(10).all()
return jsonify({
'users': [{
'id': user.id,
'username': user.username,
'email': user.email,
'avatar_url': user.get_avatar_url(32),
'role': user.role.value,
'team': user.team.name if user.team else None
} for user in users]
})

View File

@@ -11,40 +11,7 @@ while ! pg_isready -h db -p 5432 -U "$POSTGRES_USER" > /dev/null 2>&1; do
done
echo "PostgreSQL is ready!"
# Check if SQLite database exists and has data
SQLITE_PATH="/data/timetrack.db"
if [ -f "$SQLITE_PATH" ]; then
echo "SQLite database found at $SQLITE_PATH"
# Check if PostgreSQL database is empty
POSTGRES_TABLE_COUNT=$(psql "$DATABASE_URL" -t -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public';" 2>/dev/null || echo "0")
if [ "$POSTGRES_TABLE_COUNT" -eq 0 ]; then
echo "PostgreSQL database is empty, running migration..."
# Create a backup of SQLite database
cp "$SQLITE_PATH" "${SQLITE_PATH}.backup.$(date +%Y%m%d_%H%M%S)"
echo "Created SQLite backup"
# Run migration
python migrate_sqlite_to_postgres.py
if [ $? -eq 0 ]; then
echo "Migration completed successfully!"
# Rename SQLite database to indicate it's been migrated
mv "$SQLITE_PATH" "${SQLITE_PATH}.migrated"
echo "SQLite database renamed to indicate migration completion"
else
echo "Migration failed! Check migration.log for details"
exit 1
fi
else
echo "PostgreSQL database already contains tables, skipping migration"
fi
else
echo "No SQLite database found, starting with fresh PostgreSQL database"
fi
# SQLite to PostgreSQL migration is now handled by the migration system below
# Initialize database tables if they don't exist
echo "Ensuring database tables exist..."
@@ -55,6 +22,35 @@ with app.app_context():
print('Database tables created/verified')
"
# Run all database schema migrations
echo ""
echo "=== Running Database Schema Migrations ==="
if [ -d "migrations" ] && [ -f "migrations/run_all_db_migrations.py" ]; then
echo "Checking and applying database schema updates..."
python migrations/run_all_db_migrations.py
if [ $? -ne 0 ]; then
echo "⚠️ Some database migrations had issues, but continuing..."
fi
else
echo "No migrations directory found, skipping database migrations..."
fi
# Run code migrations to update code for model changes
echo ""
echo "=== Running Code Migrations ==="
echo "Code migrations temporarily disabled for debugging"
# if [ -d "migrations" ] && [ -f "migrations/run_code_migrations.py" ]; then
# echo "Checking and applying code updates for model changes..."
# python migrations/run_code_migrations.py
# if [ $? -ne 0 ]; then
# echo "⚠️ Code migrations had issues, but continuing..."
# fi
# else
# echo "No migrations directory found, skipping code migrations..."
# fi
# Start the Flask application with gunicorn
echo ""
echo "=== Starting Application ==="
echo "Starting Flask application with gunicorn..."
exec gunicorn --bind 0.0.0.0:5000 --workers 4 --threads 2 --timeout 30 app:app

40
startup_postgres.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
set -e
echo "Starting TimeTrack application (PostgreSQL-only mode)..."
# Wait for PostgreSQL to be ready
echo "Waiting for PostgreSQL to be ready..."
while ! pg_isready -h db -p 5432 -U "$POSTGRES_USER" > /dev/null 2>&1; do
echo "PostgreSQL is not ready yet. Waiting..."
sleep 2
done
echo "PostgreSQL is ready!"
# Initialize database tables if they don't exist
echo "Ensuring database tables exist..."
python -c "
from app import app, db
with app.app_context():
db.create_all()
print('Database tables created/verified')
"
# Run PostgreSQL-only migrations
echo ""
echo "=== Running PostgreSQL Migrations ==="
if [ -f "migrations/run_postgres_migrations.py" ]; then
echo "Applying PostgreSQL schema updates..."
python migrations/run_postgres_migrations.py
if [ $? -ne 0 ]; then
echo "⚠️ Some migrations failed, but continuing..."
fi
else
echo "PostgreSQL migration runner not found, skipping..."
fi
# Start the Flask application with gunicorn
echo ""
echo "=== Starting Application ==="
echo "Starting Flask application with gunicorn..."
exec gunicorn --bind 0.0.0.0:5000 --workers 4 --threads 2 --timeout 30 app:app

View File

@@ -73,7 +73,7 @@ function renderSubtasks() {
function addSubtask() {
const newSubtask = {
name: '',
status: 'NOT_STARTED',
status: 'TODO',
priority: 'MEDIUM',
assigned_to_id: null,
isNew: true
@@ -143,7 +143,7 @@ function updateSubtaskAssignee(index, assigneeId) {
// Toggle subtask status
function toggleSubtaskStatus(index) {
const subtask = currentSubtasks[index];
const newStatus = subtask.status === 'COMPLETED' ? 'NOT_STARTED' : 'COMPLETED';
const newStatus = subtask.status === 'DONE' ? 'TODO' : 'DONE';
if (subtask.id) {
// Update in database

445
static/js/time-tracking.js Normal file
View File

@@ -0,0 +1,445 @@
// Time Tracking JavaScript
document.addEventListener('DOMContentLoaded', function() {
// Project/Task Selection
const projectSelect = document.getElementById('project-select');
const taskSelect = document.getElementById('task-select');
const manualProjectSelect = document.getElementById('manual-project');
const manualTaskSelect = document.getElementById('manual-task');
// Update task dropdown when project is selected
function updateTaskDropdown(projectSelectElement, taskSelectElement) {
const projectId = projectSelectElement.value;
if (!projectId) {
taskSelectElement.disabled = true;
taskSelectElement.innerHTML = '<option value="">Select a project first</option>';
return;
}
// Fetch tasks for the selected project
fetch(`/api/projects/${projectId}/tasks`)
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.error || `HTTP error! status: ${response.status}`);
});
}
return response.json();
})
.then(data => {
taskSelectElement.disabled = false;
taskSelectElement.innerHTML = '<option value="">No specific task</option>';
if (data.tasks && data.tasks.length > 0) {
data.tasks.forEach(task => {
const option = document.createElement('option');
option.value = task.id;
option.textContent = `${task.title} (${task.status})`;
taskSelectElement.appendChild(option);
});
} else {
taskSelectElement.innerHTML = '<option value="">No tasks available</option>';
}
})
.catch(error => {
console.error('Error fetching tasks:', error);
taskSelectElement.disabled = true;
taskSelectElement.innerHTML = `<option value="">Error: ${error.message}</option>`;
});
}
if (projectSelect) {
projectSelect.addEventListener('change', () => updateTaskDropdown(projectSelect, taskSelect));
}
if (manualProjectSelect) {
manualProjectSelect.addEventListener('change', () => updateTaskDropdown(manualProjectSelect, manualTaskSelect));
}
// Start Work Form
const startWorkForm = document.getElementById('start-work-form');
if (startWorkForm) {
startWorkForm.addEventListener('submit', function(e) {
e.preventDefault();
const projectId = document.getElementById('project-select').value;
const taskId = document.getElementById('task-select').value;
const notes = document.getElementById('work-notes').value;
fetch('/api/arrive', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
project_id: projectId || null,
task_id: taskId || null,
notes: notes || null
}),
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
showNotification('Error: ' + data.message, 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('An error occurred while starting work', 'error');
});
});
}
// View Toggle
const viewToggleBtns = document.querySelectorAll('.toggle-btn');
const listView = document.getElementById('list-view');
const gridView = document.getElementById('grid-view');
viewToggleBtns.forEach(btn => {
btn.addEventListener('click', function() {
const view = this.getAttribute('data-view');
// Update button states
viewToggleBtns.forEach(b => b.classList.remove('active'));
this.classList.add('active');
// Show/hide views
if (view === 'list') {
listView.classList.add('active');
gridView.classList.remove('active');
} else {
listView.classList.remove('active');
gridView.classList.add('active');
}
// Save preference
localStorage.setItem('timeTrackingView', view);
});
});
// Restore view preference
const savedView = localStorage.getItem('timeTrackingView') || 'list';
if (savedView === 'grid') {
document.querySelector('[data-view="grid"]').click();
}
// Modal Functions
function openModal(modalId) {
document.getElementById(modalId).style.display = 'block';
document.body.style.overflow = 'hidden';
}
function closeModal(modalId) {
document.getElementById(modalId).style.display = 'none';
document.body.style.overflow = 'auto';
}
// Modal close buttons
document.querySelectorAll('.modal-close, .modal-cancel').forEach(btn => {
btn.addEventListener('click', function() {
const modal = this.closest('.modal');
closeModal(modal.id);
});
});
// Close modal on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', function() {
const modal = this.closest('.modal');
closeModal(modal.id);
});
});
// Manual Entry
const manualEntryBtn = document.getElementById('manual-entry-btn');
if (manualEntryBtn) {
manualEntryBtn.addEventListener('click', function() {
// Set default dates to today
const today = new Date().toISOString().split('T')[0];
document.getElementById('manual-start-date').value = today;
document.getElementById('manual-end-date').value = today;
openModal('manual-modal');
});
}
// Manual Entry Form Submission
const manualEntryForm = document.getElementById('manual-entry-form');
if (manualEntryForm) {
manualEntryForm.addEventListener('submit', function(e) {
e.preventDefault();
const startDate = document.getElementById('manual-start-date').value;
const startTime = document.getElementById('manual-start-time').value;
const endDate = document.getElementById('manual-end-date').value;
const endTime = document.getElementById('manual-end-time').value;
const projectId = document.getElementById('manual-project').value;
const taskId = document.getElementById('manual-task').value;
const breakMinutes = parseInt(document.getElementById('manual-break').value) || 0;
const notes = document.getElementById('manual-notes').value;
// Validate end time is after start time
const startDateTime = new Date(`${startDate}T${startTime}`);
const endDateTime = new Date(`${endDate}T${endTime}`);
if (endDateTime <= startDateTime) {
showNotification('End time must be after start time', 'error');
return;
}
fetch('/api/manual-entry', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
start_date: startDate,
start_time: startTime,
end_date: endDate,
end_time: endTime,
project_id: projectId || null,
task_id: taskId || null,
break_minutes: breakMinutes,
notes: notes || null
}),
})
.then(response => response.json())
.then(data => {
if (data.success) {
closeModal('manual-modal');
location.reload();
} else {
showNotification('Error: ' + data.message, 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('An error occurred while adding the manual entry', 'error');
});
});
}
// Edit Entry
document.querySelectorAll('.edit-entry-btn').forEach(button => {
button.addEventListener('click', function() {
const entryId = this.getAttribute('data-id');
// Fetch entry details
fetch(`/api/time-entry/${entryId}`)
.then(response => response.json())
.then(data => {
if (data.success) {
const entry = data.entry;
// Parse dates and times
const arrivalDate = new Date(entry.arrival_time);
const departureDate = entry.departure_time ? new Date(entry.departure_time) : null;
// Set form values
document.getElementById('edit-entry-id').value = entry.id;
document.getElementById('edit-arrival-date').value = arrivalDate.toISOString().split('T')[0];
document.getElementById('edit-arrival-time').value = arrivalDate.toTimeString().substring(0, 5);
if (departureDate) {
document.getElementById('edit-departure-date').value = departureDate.toISOString().split('T')[0];
document.getElementById('edit-departure-time').value = departureDate.toTimeString().substring(0, 5);
} else {
document.getElementById('edit-departure-date').value = '';
document.getElementById('edit-departure-time').value = '';
}
document.getElementById('edit-project').value = entry.project_id || '';
document.getElementById('edit-notes').value = entry.notes || '';
openModal('edit-modal');
} else {
showNotification('Error loading entry details', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('An error occurred while loading entry details', 'error');
});
});
});
// Edit Entry Form Submission
const editEntryForm = document.getElementById('edit-entry-form');
if (editEntryForm) {
editEntryForm.addEventListener('submit', function(e) {
e.preventDefault();
const entryId = document.getElementById('edit-entry-id').value;
const arrivalDate = document.getElementById('edit-arrival-date').value;
const arrivalTime = document.getElementById('edit-arrival-time').value;
const departureDate = document.getElementById('edit-departure-date').value;
const departureTime = document.getElementById('edit-departure-time').value;
const projectId = document.getElementById('edit-project').value;
const notes = document.getElementById('edit-notes').value;
// Format datetime strings
const arrivalDateTime = `${arrivalDate}T${arrivalTime}:00`;
let departureDateTime = null;
if (departureDate && departureTime) {
departureDateTime = `${departureDate}T${departureTime}:00`;
}
fetch(`/api/update/${entryId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
arrival_time: arrivalDateTime,
departure_time: departureDateTime,
project_id: projectId || null,
notes: notes || null
}),
})
.then(response => response.json())
.then(data => {
if (data.success) {
closeModal('edit-modal');
location.reload();
} else {
showNotification('Error: ' + data.message, 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('An error occurred while updating the entry', 'error');
});
});
}
// Delete Entry
document.querySelectorAll('.delete-entry-btn').forEach(button => {
button.addEventListener('click', function() {
const entryId = this.getAttribute('data-id');
document.getElementById('delete-entry-id').value = entryId;
openModal('delete-modal');
});
});
// Confirm Delete
const confirmDeleteBtn = document.getElementById('confirm-delete');
if (confirmDeleteBtn) {
confirmDeleteBtn.addEventListener('click', function() {
const entryId = document.getElementById('delete-entry-id').value;
fetch(`/api/delete/${entryId}`, {
method: 'DELETE',
})
.then(response => response.json())
.then(data => {
if (data.success) {
closeModal('delete-modal');
// Remove the row/card from the DOM
const row = document.querySelector(`tr[data-entry-id="${entryId}"]`);
const card = document.querySelector(`.entry-card[data-entry-id="${entryId}"]`);
if (row) row.remove();
if (card) card.remove();
showNotification('Entry deleted successfully', 'success');
} else {
showNotification('Error: ' + data.message, 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('An error occurred while deleting the entry', 'error');
});
});
}
// Resume Work
document.querySelectorAll('.resume-work-btn').forEach(button => {
button.addEventListener('click', function() {
// Skip if button is disabled
if (this.disabled) {
return;
}
const entryId = this.getAttribute('data-id');
fetch(`/api/resume/${entryId}`, {
method: 'POST',
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
showNotification('Error: ' + data.message, 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('An error occurred while resuming work', 'error');
});
});
});
// Notification function
function showNotification(message, type = 'info') {
// Create notification element
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
// Add to page
document.body.appendChild(notification);
// Animate in
setTimeout(() => notification.classList.add('show'), 10);
// Remove after 5 seconds
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => notification.remove(), 300);
}, 5000);
}
});
// Add notification styles
const style = document.createElement('style');
style.textContent = `
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 1rem 1.5rem;
border-radius: 8px;
background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateX(400px);
transition: transform 0.3s ease;
z-index: 9999;
max-width: 350px;
}
.notification.show {
transform: translateX(0);
}
.notification-success {
background: #d1fae5;
color: #065f46;
border: 1px solid #6ee7b7;
}
.notification-error {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fca5a5;
}
.notification-info {
background: #dbeafe;
color: #1e40af;
border: 1px solid #93c5fd;
}
`;
document.head.appendChild(style);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +1,602 @@
{% extends 'layout.html' %}
{% block content %}
<div class="admin-container">
<div class="admin-header">
<h1>Team Management</h1>
<a href="{{ url_for('create_team') }}" class="btn btn-md btn-success">Create New Team</a>
<div class="teams-admin-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon">👥</span>
Team Management
</h1>
<p class="page-subtitle">Manage teams and their members across your organization</p>
</div>
<div class="header-actions">
<a href="{{ url_for('teams.create_team') }}" class="btn btn-primary">
<span class="icon">+</span>
Create New Team
</a>
</div>
</div>
</div>
<!-- Team Statistics -->
{% if teams %}
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Members</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for team in teams %}
<tr>
<td>{{ team.name }}</td>
<td>{{ team.description }}</td>
<td>{{ team.users|length }}</td>
<td>{{ team.created_at.strftime('%Y-%m-%d') }}</td>
<td class="actions">
<a href="{{ url_for('manage_team', team_id=team.id) }}" class="button btn btn-sm btn-primary">Manage</a>
<form method="POST" action="{{ url_for('delete_team', team_id=team.id) }}" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this team?');">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No teams found. Create a team to get started.</p>
<div class="stats-section">
<div class="stat-card">
<div class="stat-value">{{ teams|length if teams else 0 }}</div>
<div class="stat-label">Total Teams</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ teams|map(attribute='users')|map('length')|sum if teams else 0 }}</div>
<div class="stat-label">Total Members</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ (teams|map(attribute='users')|map('length')|sum / teams|length)|round(1) if teams else 0 }}</div>
<div class="stat-label">Avg Team Size</div>
</div>
</div>
{% endif %}
<!-- Main Content -->
<div class="teams-content">
{% if teams %}
<!-- Search Bar -->
<div class="search-section">
<div class="search-container">
<span class="search-icon">🔍</span>
<input type="text"
class="search-input"
id="teamSearch"
placeholder="Search teams by name or description...">
</div>
</div>
<!-- Teams Grid -->
<div class="teams-grid" id="teamsGrid">
{% for team in teams %}
<div class="team-card" data-team-name="{{ team.name.lower() }}" data-team-desc="{{ team.description.lower() if team.description else '' }}">
<div class="team-header">
<div class="team-icon-wrapper">
<span class="team-icon">👥</span>
</div>
<div class="team-meta">
<span class="member-count">{{ team.users|length }} members</span>
</div>
</div>
<div class="team-body">
<h3 class="team-name">{{ team.name }}</h3>
<p class="team-description">
{{ team.description if team.description else 'No description provided' }}
</p>
<div class="team-info">
<div class="info-item">
<span class="info-icon">📅</span>
<span class="info-text">Created {{ team.created_at.strftime('%b %d, %Y') }}</span>
</div>
{% if team.users %}
<div class="info-item">
<span class="info-icon">👤</span>
<span class="info-text">Led by {{ team.users[0].username }}</span>
</div>
{% endif %}
</div>
<!-- Member Avatars -->
{% if team.users %}
<div class="member-avatars">
{% for member in team.users[:5] %}
<div class="member-avatar" title="{{ member.username }}">
{{ member.username[:2].upper() }}
</div>
{% endfor %}
{% if team.users|length > 5 %}
<div class="member-avatar more" title="{{ team.users|length - 5 }} more members">
+{{ team.users|length - 5 }}
</div>
{% endif %}
</div>
{% endif %}
</div>
<div class="team-actions">
<a href="{{ url_for('teams.manage_team', team_id=team.id) }}" class="btn btn-manage">
<span class="icon">⚙️</span>
Manage Team
</a>
<form method="POST"
action="{{ url_for('teams.delete_team', team_id=team.id) }}"
class="delete-form"
onsubmit="return confirm('Are you sure you want to delete the team \"{{ team.name }}\"? This action cannot be undone.');">
<button type="submit" class="btn btn-delete" title="Delete Team">
<span class="icon">🗑️</span>
</button>
</form>
</div>
</div>
{% endfor %}
</div>
<!-- No Results Message -->
<div class="no-results" id="noResults" style="display: none;">
<div class="empty-icon">🔍</div>
<p class="empty-message">No teams found matching your search</p>
<p class="empty-hint">Try searching with different keywords</p>
</div>
{% else %}
<!-- Empty State -->
<div class="empty-state">
<div class="empty-icon">👥</div>
<h2 class="empty-title">No Teams Yet</h2>
<p class="empty-message">Create your first team to start organizing your workforce</p>
<a href="{{ url_for('teams.create_team') }}" class="btn btn-primary btn-lg">
<span class="icon">+</span>
Create First Team
</a>
</div>
{% endif %}
</div>
</div>
<style>
/* Container */
.teams-admin-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
/* Page Header */
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 2rem;
margin-bottom: 2rem;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-icon {
font-size: 2.5rem;
display: inline-block;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.page-subtitle {
font-size: 1.1rem;
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
/* Stats Section */
.stats-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 12px;
text-align: center;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: #667eea;
}
.stat-label {
font-size: 0.9rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
/* Search Section */
.search-section {
margin-bottom: 2rem;
}
.search-container {
position: relative;
max-width: 500px;
}
.search-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
font-size: 1.25rem;
opacity: 0.6;
}
.search-input {
width: 100%;
padding: 1rem 1rem 1rem 3rem;
border: 2px solid #e5e7eb;
border-radius: 12px;
font-size: 1rem;
transition: all 0.3s ease;
background: white;
}
.search-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* Teams Grid */
.teams-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
/* Team Card */
.team-card {
background: white;
border-radius: 12px;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
overflow: hidden;
display: flex;
flex-direction: column;
}
.team-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.team-header {
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
padding: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.team-icon-wrapper {
width: 60px;
height: 60px;
background: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.team-icon {
font-size: 2rem;
}
.member-count {
background: white;
color: #6b7280;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 600;
}
.team-body {
padding: 1.5rem;
flex: 1;
display: flex;
flex-direction: column;
}
.team-name {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
margin: 0 0 0.5rem 0;
}
.team-description {
color: #6b7280;
line-height: 1.6;
margin-bottom: 1.5rem;
flex: 1;
}
.team-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.info-item {
display: flex;
align-items: center;
gap: 0.5rem;
color: #6b7280;
font-size: 0.875rem;
}
.info-icon {
font-size: 1rem;
}
/* Member Avatars */
.member-avatars {
display: flex;
margin-bottom: 1rem;
}
.member-avatar {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
font-weight: 600;
margin-right: -8px;
border: 2px solid white;
position: relative;
z-index: 1;
transition: all 0.2s ease;
}
.member-avatar:hover {
transform: scale(1.1);
z-index: 10;
}
.member-avatar.more {
background: #6b7280;
font-size: 0.75rem;
}
/* Team Actions */
.team-actions {
padding: 1.5rem;
background: #f8f9fa;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 0.75rem;
align-items: center;
}
.delete-form {
margin: 0;
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-manage {
background: white;
color: #667eea;
border: 2px solid #e5e7eb;
flex: 1;
justify-content: center;
}
.btn-manage:hover {
background: #f3f4f6;
border-color: #667eea;
}
.btn-delete {
background: #fee2e2;
color: #dc2626;
padding: 0.75rem;
min-width: auto;
}
.btn-delete:hover {
background: #dc2626;
color: white;
}
.btn-lg {
padding: 1rem 2rem;
font-size: 1.1rem;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 12px;
border: 2px dashed #e5e7eb;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1.5rem;
opacity: 0.3;
}
.empty-title {
font-size: 1.75rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 0.5rem;
}
.empty-message {
font-size: 1.1rem;
color: #6b7280;
margin-bottom: 2rem;
}
.empty-hint {
color: #9ca3af;
font-size: 0.875rem;
}
/* No Results */
.no-results {
text-align: center;
padding: 3rem;
}
/* Responsive Design */
@media (max-width: 768px) {
.teams-admin-container {
padding: 1rem;
}
.page-header {
padding: 1.5rem;
}
.page-title {
font-size: 2rem;
}
.header-content {
flex-direction: column;
text-align: center;
}
.header-stats {
grid-template-columns: 1fr;
}
.teams-grid {
grid-template-columns: 1fr;
}
.team-actions {
flex-direction: column;
}
.btn-manage {
width: 100%;
}
}
/* Animations */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.team-card {
animation: slideIn 0.4s ease-out;
animation-fill-mode: both;
}
.team-card:nth-child(1) { animation-delay: 0.05s; }
.team-card:nth-child(2) { animation-delay: 0.1s; }
.team-card:nth-child(3) { animation-delay: 0.15s; }
.team-card:nth-child(4) { animation-delay: 0.2s; }
.team-card:nth-child(5) { animation-delay: 0.25s; }
.team-card:nth-child(6) { animation-delay: 0.3s; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('teamSearch');
const teamsGrid = document.getElementById('teamsGrid');
const noResults = document.getElementById('noResults');
if (searchInput && teamsGrid) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase().trim();
const teamCards = teamsGrid.querySelectorAll('.team-card');
let visibleCount = 0;
teamCards.forEach(card => {
const teamName = card.getAttribute('data-team-name');
const teamDesc = card.getAttribute('data-team-desc');
if (teamName.includes(searchTerm) || teamDesc.includes(searchTerm)) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
// Show/hide no results message
if (noResults) {
noResults.style.display = visibleCount === 0 ? 'block' : 'none';
}
});
}
});
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -45,9 +45,9 @@
<div class="form-group">
<label for="work_hours_per_day">Standard Work Hours Per Day:</label>
<input type="number"
id="work_hours_per_day"
name="work_hours_per_day"
value="{{ work_config.work_hours_per_day }}"
id="standard_hours_per_day"
name="standard_hours_per_day"
value="{{ work_config.standard_hours_per_day }}"
min="1"
max="24"
step="0.5"
@@ -61,9 +61,9 @@
<div class="form-group">
<label for="mandatory_break_minutes">Mandatory Break Duration (minutes):</label>
<input type="number"
id="mandatory_break_minutes"
name="mandatory_break_minutes"
value="{{ work_config.mandatory_break_minutes }}"
id="break_duration_minutes"
name="break_duration_minutes"
value="{{ work_config.break_duration_minutes }}"
min="0"
max="240"
required>
@@ -73,9 +73,9 @@
<div class="form-group">
<label for="break_threshold_hours">Break Threshold (hours):</label>
<input type="number"
id="break_threshold_hours"
name="break_threshold_hours"
value="{{ work_config.break_threshold_hours }}"
id="break_after_hours"
name="break_after_hours"
value="{{ work_config.break_after_hours }}"
min="0"
max="24"
step="0.5"
@@ -116,11 +116,11 @@
<div class="current-config">
<h4>Current Configuration Summary</h4>
<div class="config-summary">
<strong>Region:</strong> {{ work_config.region_name }}<br>
<strong>Work Day:</strong> {{ work_config.work_hours_per_day }} hours<br>
<strong>Region:</strong> {{ work_config.work_region.value }}<br>
<strong>Work Day:</strong> {{ work_config.standard_hours_per_day }} hours<br>
<strong>Break Policy:</strong>
{% if work_config.mandatory_break_minutes > 0 %}
{{ work_config.mandatory_break_minutes }} minutes after {{ work_config.break_threshold_hours }} hours
{{ work_config.break_duration_minutes }} minutes after {{ work_config.break_after_hours }} hours
{% else %}
No mandatory breaks
{% endif %}
@@ -135,7 +135,7 @@
</div>
<button type="submit" class="btn btn-primary">Save Custom Configuration</button>
<a href="{{ url_for('admin_company') }}" class="btn btn-secondary">Back to Company Settings</a>
<a href="{{ url_for('companies.admin_company') }}" class="btn btn-secondary">Back to Company Settings</a>
</form>
</div>
</div>

View File

@@ -120,16 +120,16 @@
</div>
<div class="chart-stats">
<div class="stat-card">
<h4>Total Hours</h4>
<span id="total-hours">0</span>
<h4 id="stat-label-1">Total Hours</h4>
</div>
<div class="stat-card">
<h4>Total Days</h4>
<span id="total-days">0</span>
<h4 id="stat-label-2">Total Days</h4>
</div>
<div class="stat-card">
<h4>Average Hours/Day</h4>
<span id="avg-hours">0</span>
<h4 id="stat-label-3">Average Hours/Day</h4>
</div>
</div>
</div>
@@ -417,9 +417,9 @@ class TimeAnalyticsController {
document.getElementById('avg-hours').textContent = data.burndown.tasks_completed || '0';
// Update stat labels for burndown
document.querySelector('.stat-card:nth-child(1) h4').textContent = 'Total Tasks';
document.querySelector('.stat-card:nth-child(2) h4').textContent = 'Timeline Days';
document.querySelector('.stat-card:nth-child(3) h4').textContent = 'Completed Tasks';
document.getElementById('stat-label-1').textContent = 'Total Tasks';
document.getElementById('stat-label-2').textContent = 'Timeline Days';
document.getElementById('stat-label-3').textContent = 'Completed Tasks';
} else {
document.getElementById('total-hours').textContent = data.totalHours?.toFixed(1) || '0';
document.getElementById('total-days').textContent = data.totalDays || '0';
@@ -427,9 +427,9 @@ class TimeAnalyticsController {
data.totalDays > 0 ? (data.totalHours / data.totalDays).toFixed(1) : '0';
// Restore original stat labels
document.querySelector('.stat-card:nth-child(1) h4').textContent = 'Total Hours';
document.querySelector('.stat-card:nth-child(2) h4').textContent = 'Total Days';
document.querySelector('.stat-card:nth-child(3) h4').textContent = 'Average Hours/Day';
document.getElementById('stat-label-1').textContent = 'Total Hours';
document.getElementById('stat-label-2').textContent = 'Total Days';
document.getElementById('stat-label-3').textContent = 'Average Hours/Day';
}
this.updateChart();

View File

@@ -4,7 +4,7 @@
<div class="admin-container">
<div class="admin-header">
<h1>Company Users - {{ company.name }}</h1>
<a href="{{ url_for('create_user') }}" class="btn btn-success">Create New User</a>
<a href="{{ url_for('users.create_user') }}" class="btn btn-success">Create New User</a>
</div>
<!-- User Statistics -->
@@ -84,13 +84,15 @@
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<a href="{{ url_for('edit_user', user_id=user.id) }}" class="btn btn-sm btn-primary">Edit</a>
<a href="{{ url_for('users.edit_user', user_id=user.id) }}" class="btn btn-sm btn-primary">Edit</a>
{% if user.id != g.user.id %}
<form method="POST" action="{{ url_for('users.toggle_user_status', user_id=user.id) }}" style="display: inline;">
{% if user.is_blocked %}
<a href="{{ url_for('toggle_user_status', user_id=user.id) }}" class="btn btn-sm btn-success">Unblock</a>
<button type="submit" class="btn btn-sm btn-success">Unblock</button>
{% else %}
<a href="{{ url_for('toggle_user_status', user_id=user.id) }}" class="btn btn-sm btn-warning">Block</a>
<button type="submit" class="btn btn-sm btn-warning">Block</button>
{% endif %}
</form>
<button class="btn btn-sm btn-danger" onclick="confirmDelete({{ user.id }}, '{{ user.username }}')">Delete</button>
{% endif %}
</td>
@@ -103,14 +105,14 @@
<div class="empty-state">
<h3>No Users Found</h3>
<p>There are no users in this company yet.</p>
<a href="{{ url_for('create_user') }}" class="btn btn-primary">Add First User</a>
<a href="{{ url_for('users.create_user') }}" class="btn btn-primary">Add First User</a>
</div>
{% endif %}
</div>
<!-- Navigation -->
<div class="admin-section">
<a href="{{ url_for('admin_company') }}" class="btn btn-secondary">← Back to Company Management</a>
<a href="{{ url_for('companies.admin_company') }}" class="btn btn-secondary">← Back to Company Management</a>
</div>
</div>

View File

@@ -18,15 +18,15 @@
<div class="policy-info">
<div class="policy-item">
<strong>Region:</strong> {{ company_config.region_name }}
<strong>Region:</strong> {{ company_config.work_region.value }}
</div>
<div class="policy-item">
<strong>Standard Work Day:</strong> {{ company_config.work_hours_per_day }} hours
<strong>Standard Work Day:</strong> {{ company_config.standard_hours_per_day }} hours
</div>
<div class="policy-item">
<strong>Break Policy:</strong>
{% if company_config.mandatory_break_minutes > 0 %}
{{ company_config.mandatory_break_minutes }} minutes after {{ company_config.break_threshold_hours }} hours
{{ company_config.break_duration_minutes }} minutes after {{ company_config.break_after_hours }} hours
{% else %}
No mandatory breaks
{% endif %}

View File

@@ -5,7 +5,7 @@
<div class="header-section">
<h1>⚠️ Confirm Company Deletion</h1>
<p class="subtitle">Critical Action Required - Review All Data Before Proceeding</p>
<a href="{{ url_for('admin_users') if g.user.role != Role.SYSTEM_ADMIN else url_for('system_admin_users') }}"
<a href="{{ url_for('users.admin_users') if g.user.role != Role.SYSTEM_ADMIN else url_for('users.system_admin_users') }}"
class="btn btn-md btn-secondary">← Back to User Management</a>
</div>
@@ -231,7 +231,7 @@
</div>
<div class="form-group">
<a href="{{ url_for('admin_users') if g.user.role != Role.SYSTEM_ADMIN else url_for('system_admin_users') }}"
<a href="{{ url_for('users.admin_users') if g.user.role != Role.SYSTEM_ADMIN else url_for('users.system_admin_users') }}"
class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-danger">
Delete Company and All Data

View File

@@ -4,7 +4,7 @@
<div class="timetrack-container">
<h2>Create New Project</h2>
<form method="POST" action="{{ url_for('create_project') }}" class="project-form">
<form method="POST" action="{{ url_for('projects.create_project') }}" class="project-form">
<div class="form-row">
<div class="form-group">
<label for="name">Project Name *</label>
@@ -71,7 +71,7 @@
<div class="form-actions">
<button type="submit" class="btn">Create Project</button>
<a href="{{ url_for('admin_projects') }}" class="btn btn-secondary">Cancel</a>
<a href="{{ url_for('projects.admin_projects') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>

View File

@@ -1,24 +0,0 @@
{% extends "layout.html" %}
{% block content %}
<div class="admin-container">
<h1>Create New Team</h1>
<form method="POST" action="{{ url_for('create_team') }}" class="team-form">
<div class="form-group">
<label for="name">Team Name</label>
<input type="text" id="name" name="name" class="form-control" required>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Create Team</button>
<a href="{{ url_for('admin_teams') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -4,7 +4,7 @@
<div class="admin-container">
<h1>Create New User</h1>
<form method="POST" action="{{ url_for('create_user') }}" class="user-form">
<form method="POST" action="{{ url_for('users.create_user') }}" class="user-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" class="form-control" required autofocus>
@@ -48,7 +48,7 @@
<div class="form-group">
<button type="submit" class="btn btn-success">Create User</button>
<a href="{{ url_for('admin_users') }}" class="btn btn-secondary">Cancel</a>
<a href="{{ url_for('users.admin_users') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>

View File

@@ -918,6 +918,7 @@ function loadDashboard() {
}
function renderDashboard() {
console.log('Rendering dashboard with widgets:', widgets);
const grid = document.getElementById('dashboard-grid');
const emptyMessage = document.getElementById('empty-dashboard');
@@ -930,6 +931,10 @@ function renderDashboard() {
grid.style.display = 'grid';
emptyMessage.style.display = 'none';
// Clear timer intervals before clearing widgets
Object.values(timerIntervals).forEach(interval => clearInterval(interval));
timerIntervals = {};
// Clear existing widgets
grid.innerHTML = '';
@@ -939,8 +944,11 @@ function renderDashboard() {
return a.grid_x - b.grid_x;
});
console.log('Sorted widgets:', widgets);
// Render each widget
widgets.forEach(widget => {
console.log('Creating widget element for:', widget);
const widgetElement = createWidgetElement(widget);
grid.appendChild(widgetElement);
});
@@ -949,6 +957,9 @@ function renderDashboard() {
if (isCustomizing) {
initializeDragAndDrop();
}
// Reset global timer state to force refresh
globalTimerState = null;
}
function createWidgetElement(widget) {
@@ -1397,6 +1408,7 @@ function configureWidget(widgetId) {
}
function removeWidget(widgetId) {
console.log('Removing widget with ID:', widgetId);
if (!confirm('Are you sure you want to remove this widget?')) return;
fetch(`/api/dashboard/widgets/${widgetId}`, {
@@ -1404,6 +1416,7 @@ function removeWidget(widgetId) {
})
.then(response => response.json())
.then(data => {
console.log('Remove widget response:', data);
if (data.success) {
loadDashboard();
} else {

View File

@@ -1,73 +0,0 @@
{% extends "layout.html" %}
{% block content %}
<div class="admin-container">
<h1>Edit Company</h1>
<form method="POST" class="user-form">
<div class="form-group">
<label for="name">Company Name</label>
<input type="text" id="name" name="name" class="form-control"
value="{{ company.name }}" required autofocus>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" class="form-control"
rows="3">{{ company.description or '' }}</textarea>
<small class="form-help">Optional description of your company</small>
</div>
<div class="form-group">
<label for="max_users">Maximum Users</label>
<input type="number" id="max_users" name="max_users" class="form-control"
value="{{ company.max_users or '' }}" min="1">
<small class="form-help">Leave empty for unlimited users</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="is_active" name="is_active"
{{ 'checked' if company.is_active else '' }}>
Company is active
</label>
<small class="form-help">Inactive companies cannot be accessed by users</small>
</div>
<div class="info-box">
<h3>Company Code</h3>
<p><strong>{{ company.slug }}</strong></p>
<p>This code cannot be changed and is used by new users to register for your company.</p>
</div>
<div class="form-actions">
<a href="{{ url_for('admin_company') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
<style>
.info-box {
background: #e7f3ff;
border: 1px solid #b3d9ff;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.info-box h3 {
margin-top: 0;
color: #0066cc;
}
.info-box p {
margin-bottom: 10px;
}
.info-box p:last-child {
margin-bottom: 0;
color: #666;
}
</style>
{% endblock %}

View File

@@ -4,7 +4,7 @@
<div class="timetrack-container">
<h2>Edit Project: {{ project.name }}</h2>
<form method="POST" action="{{ url_for('edit_project', project_id=project.id) }}" class="project-form">
<form method="POST" action="{{ url_for('projects.edit_project', project_id=project.id) }}" class="project-form">
<div class="form-row">
<div class="form-group">
<label for="name">Project Name *</label>
@@ -96,9 +96,29 @@
<div class="form-actions">
<button type="submit" class="btn">Update Project</button>
<a href="{{ url_for('admin_projects') }}" class="btn btn-secondary">Cancel</a>
<a href="{{ url_for('projects.admin_projects') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
<!-- Danger Zone (only for admins) -->
{% if g.user.role in [Role.ADMIN, Role.SYSTEM_ADMIN] %}
<div class="danger-zone">
<h3>⚠️ Danger Zone</h3>
<div class="danger-content">
<p><strong>Delete Project</strong></p>
<p>Once you delete a project, there is no going back. This will permanently delete:</p>
<ul>
<li>All tasks and subtasks in this project</li>
<li>All time entries logged to this project</li>
<li>All sprints associated with this project</li>
<li>All comments and activity history</li>
</ul>
<form method="POST" action="{{ url_for('projects.delete_project', project_id=project.id) }}" onsubmit="return confirm('Are you absolutely sure you want to delete {{ project.name }}? This action cannot be undone!');">
<button type="submit" class="btn btn-danger">Delete This Project</button>
</form>
</div>
</div>
{% endif %}
</div>
<style>
@@ -194,6 +214,47 @@
#code {
text-transform: uppercase;
}
/* Danger Zone */
.danger-zone {
margin-top: 3rem;
padding: 1.5rem;
background-color: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.danger-zone h3 {
color: #dc2626;
margin-top: 0;
margin-bottom: 1rem;
}
.danger-content {
color: #7f1d1d;
}
.danger-content p {
margin-bottom: 0.5rem;
}
.danger-content ul {
margin: 1rem 0 1.5rem 2rem;
}
.danger-content .btn-danger {
background-color: #dc2626;
color: white;
border: none;
padding: 0.5rem 1.5rem;
}
.danger-content .btn-danger:hover {
background-color: #b91c1c;
}
</style>
<script>

View File

@@ -4,7 +4,7 @@
<div class="admin-container">
<h1>Edit User: {{ user.username }}</h1>
<form method="POST" action="{{ url_for('edit_user', user_id=user.id) }}" class="user-form">
<form method="POST" action="{{ url_for('users.edit_user', user_id=user.id) }}" class="user-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" class="form-control" value="{{ user.username }}" required>
@@ -46,7 +46,7 @@
<div class="form-group">
<button type="submit" class="btn btn-primary">Update User</button>
<a href="{{ url_for('admin_users') }}" class="btn btn-secondary">Cancel</a>
<a href="{{ url_for('users.admin_users') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>

View File

@@ -0,0 +1,152 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invitation to {{ invitation.company.name }}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: white;
border-radius: 8px;
padding: 40px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
}
.logo {
font-size: 48px;
margin-bottom: 10px;
}
h1 {
color: #667eea;
font-size: 24px;
margin: 0;
}
.content {
margin-bottom: 30px;
}
.invitation-box {
background-color: #f8f9fa;
border-left: 4px solid #667eea;
padding: 20px;
margin: 20px 0;
}
.cta-button {
display: inline-block;
padding: 14px 30px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
margin: 20px 0;
}
.details {
background-color: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
.details-item {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.details-label {
font-weight: 600;
color: #6b7280;
}
.footer {
text-align: center;
color: #6b7280;
font-size: 14px;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
}
.custom-message {
background-color: #ede9fe;
border-left: 4px solid #5b21b6;
padding: 15px;
margin: 20px 0;
font-style: italic;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">📨</div>
<h1>You're Invited to Join {{ invitation.company.name }}!</h1>
</div>
<div class="content">
<p>Hello,</p>
<p><strong>{{ sender.username }}</strong> has invited you to join <strong>{{ invitation.company.name }}</strong> on {{ g.branding.app_name }}.</p>
{% if custom_message %}
<div class="custom-message">
<strong>Personal message from {{ sender.username }}:</strong><br>
{{ custom_message }}
</div>
{% endif %}
<div class="invitation-box">
<h3 style="margin-top: 0;">Your Invitation Details:</h3>
<div class="details-item">
<span class="details-label">Company:</span>
<span>{{ invitation.company.name }}</span>
</div>
<div class="details-item">
<span class="details-label">Role:</span>
<span>{{ invitation.role }}</span>
</div>
<div class="details-item">
<span class="details-label">Invited by:</span>
<span>{{ sender.username }}</span>
</div>
<div class="details-item">
<span class="details-label">Expires:</span>
<span>{{ invitation.expires_at.strftime('%B %d, %Y') }}</span>
</div>
</div>
<div style="text-align: center;">
<a href="{{ invitation_url }}" class="cta-button">Accept Invitation</a>
<p style="font-size: 14px; color: #6b7280;">
Or copy and paste this link:<br>
<code style="background: #f3f4f6; padding: 5px; border-radius: 4px;">{{ invitation_url }}</code>
</p>
</div>
<div class="details">
<h3 style="margin-top: 0;">What happens next?</h3>
<ul style="margin: 0; padding-left: 20px;">
<li>Click the link above to accept the invitation</li>
<li>Create your account with a username and password</li>
<li>You'll automatically join {{ invitation.company.name }}</li>
<li>Start tracking your time and collaborating with your team!</li>
</ul>
</div>
</div>
<div class="footer">
<p>This invitation will expire on <strong>{{ invitation.expires_at.strftime('%B %d, %Y') }}</strong>.</p>
<p>If you didn't expect this invitation, you can safely ignore this email.</p>
<p>&copy; {{ g.branding.app_name }} - Time Tracking Made Simple</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reminder: Invitation to {{ invitation.company.name }}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: white;
border-radius: 8px;
padding: 40px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
}
.reminder-badge {
display: inline-block;
background: #f59e0b;
color: white;
padding: 5px 15px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
}
.logo {
font-size: 48px;
margin-bottom: 10px;
}
h1 {
color: #667eea;
font-size: 24px;
margin: 0;
}
.cta-button {
display: inline-block;
padding: 14px 30px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
margin: 20px 0;
}
.expiry-warning {
background-color: #fef3c7;
border-left: 4px solid #f59e0b;
padding: 15px;
margin: 20px 0;
border-radius: 4px;
}
.footer {
text-align: center;
color: #6b7280;
font-size: 14px;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="reminder-badge">REMINDER</div>
<div class="logo">📨</div>
<h1>Your Invitation is Still Waiting!</h1>
</div>
<div class="content">
<p>Hello,</p>
<p>This is a friendly reminder that you still have a pending invitation to join <strong>{{ invitation.company.name }}</strong> on {{ g.branding.app_name }}.</p>
<div class="expiry-warning">
<strong>⏰ Time is running out!</strong><br>
This invitation will expire on <strong>{{ invitation.expires_at.strftime('%B %d, %Y') }}</strong>.
</div>
<p>Don't miss out on joining your team! Click the button below to accept your invitation and create your account:</p>
<div style="text-align: center;">
<a href="{{ invitation_url }}" class="cta-button">Accept Invitation Now</a>
<p style="font-size: 14px; color: #6b7280;">
Or copy and paste this link:<br>
<code style="background: #f3f4f6; padding: 5px; border-radius: 4px;">{{ invitation_url }}</code>
</p>
</div>
</div>
<div class="footer">
<p>If you're no longer interested in joining {{ invitation.company.name }}, you can safely ignore this email.</p>
<p>&copy; {{ g.branding.app_name }} - Time Tracking Made Simple</p>
</div>
</div>
</body>
</html>

View File

@@ -7,7 +7,7 @@
<div class="export-options">
<div class="export-section">
<h3>Date Range</h3>
<form action="{{ url_for('download_export') }}" method="get">
<form action="{{ url_for('export.download_export') }}" method="get">
<div class="form-group">
<label for="start_date">Start Date:</label>
<input type="date" id="start_date" name="start_date" required>
@@ -33,14 +33,14 @@
<div class="export-section">
<h3>Quick Export</h3>
<div class="quick-export-buttons">
<a href="{{ url_for('download_export', period='today', format='csv') }}" class="btn">Today (CSV)</a>
<a href="{{ url_for('download_export', period='today', format='excel') }}" class="btn">Today (Excel)</a>
<a href="{{ url_for('download_export', period='week', format='csv') }}" class="btn">This Week (CSV)</a>
<a href="{{ url_for('download_export', period='week', format='excel') }}" class="btn">This Week (Excel)</a>
<a href="{{ url_for('download_export', period='month', format='csv') }}" class="btn">This Month (CSV)</a>
<a href="{{ url_for('download_export', period='month', format='excel') }}" class="btn">This Month (Excel)</a>
<a href="{{ url_for('download_export', period='all', format='csv') }}" class="btn">All Time (CSV)</a>
<a href="{{ url_for('download_export', period='all', format='excel') }}" class="btn">All Time (Excel)</a>
<a href="{{ url_for('export.download_export', period='today', format='csv') }}" class="btn">Today (CSV)</a>
<a href="{{ url_for('export.download_export', period='today', format='excel') }}" class="btn">Today (Excel)</a>
<a href="{{ url_for('export.download_export', period='week', format='csv') }}" class="btn">This Week (CSV)</a>
<a href="{{ url_for('export.download_export', period='week', format='excel') }}" class="btn">This Week (Excel)</a>
<a href="{{ url_for('export.download_export', period='month', format='csv') }}" class="btn">This Month (CSV)</a>
<a href="{{ url_for('export.download_export', period='month', format='excel') }}" class="btn">This Month (Excel)</a>
<a href="{{ url_for('export.download_export', period='all', format='csv') }}" class="btn">All Time (CSV)</a>
<a href="{{ url_for('export.download_export', period='all', format='excel') }}" class="btn">All Time (Excel)</a>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

938
templates/index_old.html Normal file
View File

@@ -0,0 +1,938 @@
{% extends "layout.html" %}
{% block content %}
{% if not g.user %}
<!-- Decadent Splash Page -->
<div class="splash-container">
<!-- Hero Section -->
<section class="splash-hero">
<div class="hero-content">
<h1 class="hero-title">Transform Your Productivity</h1>
<p class="hero-subtitle">Experience the future of time management with {{ g.branding.app_name if g.branding else 'TimeTrack' }}'s intelligent tracking system</p>
<div class="cta-buttons">
<a href="{{ url_for('register') }}" class="btn-primary">Get Started Free</a>
<a href="{{ url_for('login') }}" class="btn-secondary">Sign In</a>
</div>
</div>
<div class="hero-visual">
<div class="floating-clock">
<div class="clock-face">
<div class="hour-hand"></div>
<div class="minute-hand"></div>
<div class="second-hand"></div>
</div>
</div>
</div>
</section>
<!-- Features Grid -->
<section class="features-grid">
<h2 class="section-title">Powerful Features for Modern Teams</h2>
<div class="feature-cards">
<div class="feature-card">
<div class="feature-icon"></div>
<h3>Lightning Fast</h3>
<p>Start tracking in seconds with our intuitive one-click interface</p>
</div>
<div class="feature-card">
<div class="feature-icon">📊</div>
<h3>Advanced Analytics</h3>
<p>Gain insights with comprehensive reports and visual dashboards</p>
</div>
<div class="feature-card">
<div class="feature-icon">🏃‍♂️</div>
<h3>Sprint Management</h3>
<p>Organize work into sprints with agile project tracking</p>
</div>
<div class="feature-card">
<div class="feature-icon">👥</div>
<h3>Team Collaboration</h3>
<p>Manage teams, projects, and resources all in one place</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔒</div>
<h3>Enterprise Security</h3>
<p>Bank-level encryption with role-based access control</p>
</div>
<div class="feature-card">
<div class="feature-icon">🌐</div>
<h3>Multi-Company Support</h3>
<p>Perfect for agencies managing multiple client accounts</p>
</div>
</div>
</section>
<!-- Why Choose Section -->
<section class="statistics">
<h2 class="section-title">Why Choose {{ g.branding.app_name if g.branding else 'TimeTrack' }}?</h2>
<div class="stat-item">
<div class="stat-number">100%</div>
<div class="stat-label">Free & Open Source</div>
</div>
<div class="stat-item">
<div class="stat-number"></div>
<div class="stat-label">Unlimited Tracking</div>
</div>
<div class="stat-item">
<div class="stat-number">0</div>
<div class="stat-label">Hidden Fees</div>
</div>
<div class="stat-item">
<div class="stat-number">24/7</div>
<div class="stat-label">Always Available</div>
</div>
</section>
<!-- Getting Started Section -->
<section class="testimonials">
<h2 class="section-title">Get Started in Minutes</h2>
<div class="testimonial-grid">
<div class="testimonial-card">
<div class="feature-icon">1</div>
<h3>Sign Up</h3>
<p>Create your free account in seconds. No credit card required.</p>
</div>
<div class="testimonial-card">
<div class="feature-icon">2</div>
<h3>Set Up Your Workspace</h3>
<p>Add your company, teams, and projects to organize your time tracking.</p>
</div>
<div class="testimonial-card">
<div class="feature-icon">3</div>
<h3>Start Tracking</h3>
<p>Click "Arrive" to start tracking, "Leave" when done. It's that simple!</p>
</div>
</div>
</section>
<!-- Open Source Section -->
<section class="pricing">
<h2 class="section-title">Forever Free, Forever Open</h2>
<div class="pricing-cards">
<div class="pricing-card featured">
<div class="badge">100% Free</div>
<h3>{{ g.branding.app_name if g.branding else 'TimeTrack' }} Community</h3>
<div class="price">$0<span>/forever</span></div>
<ul class="pricing-features">
<li>✓ Unlimited users</li>
<li>✓ All features included</li>
<li>✓ Time tracking & analytics</li>
<li>✓ Sprint management</li>
<li>✓ Team collaboration</li>
<li>✓ Project management</li>
<li>✓ Self-hosted option</li>
<li>✓ No restrictions</li>
</ul>
<a href="{{ url_for('register') }}" class="btn-pricing">Get Started Free</a>
</div>
</div>
<p style="text-align: center; margin-top: 2rem; color: #666;">
The software {{ g.branding.app_name if g.branding else 'TimeTrack' }} runs is open source software.<br />
Host it yourself or use our free hosted version.<br />
The source is available on GitHub:
<a href="https://github.com/nullmedium/TimeTrack" target="_blank">https://github.com/nullmedium/TimeTrack</a>
</p>
</section>
<!-- Final CTA -->
<section class="final-cta">
<h2>Ready to Take Control of Your Time?</h2>
<p>Start tracking your time effectively today - no strings attached</p>
<a href="{{ url_for('register') }}" class="btn-primary large">Create Free Account</a>
</section>
</div>
{% else %}
<!-- Include the modern time tracking interface from time_tracking.html -->
<div class="time-tracking-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon">⏱️</span>
Time Tracking
</h1>
<p class="page-subtitle">Track your work hours efficiently</p>
</div>
<div class="header-actions">
<button id="manual-entry-btn" class="btn btn-secondary">
<span class="icon">📝</span>
Manual Entry
</button>
<a href="{{ url_for('analytics') }}" class="btn btn-secondary">
<span class="icon">📊</span>
View Analytics
</a>
</div>
</div>
</div>
<!-- Timer Section -->
<div class="timer-section">
{% if active_entry %}
<!-- Active Timer -->
<div class="timer-card active">
<div class="timer-display">
<div class="timer-value" id="timer"
data-start="{{ active_entry.arrival_time.timestamp() }}"
data-breaks="{{ active_entry.total_break_duration }}"
data-paused="{{ 'true' if active_entry.is_paused else 'false' }}">
00:00:00
</div>
<div class="timer-status">
{% if active_entry.is_paused %}
<span class="status-badge paused">On Break</span>
{% else %}
<span class="status-badge active">Working</span>
{% endif %}
</div>
</div>
<div class="timer-info">
<div class="info-row">
<span class="info-label">Started:</span>
<span class="info-value">{{ active_entry.arrival_time|format_datetime }}</span>
</div>
{% if active_entry.project %}
<div class="info-row">
<span class="info-label">Project:</span>
<span class="info-value project-badge" style="background-color: {{ active_entry.project.color or '#667eea' }}">
{{ active_entry.project.code }} - {{ active_entry.project.name }}
</span>
</div>
{% endif %}
{% if active_entry.task %}
<div class="info-row">
<span class="info-label">Task:</span>
<span class="info-value task-badge">
{{ active_entry.task.title }}
</span>
</div>
{% endif %}
{% if active_entry.notes %}
<div class="info-row">
<span class="info-label">Notes:</span>
<span class="info-value">{{ active_entry.notes }}</span>
</div>
{% endif %}
{% if active_entry.is_paused %}
<div class="info-row">
<span class="info-label">Break started:</span>
<span class="info-value">{{ active_entry.pause_start_time|format_time }}</span>
</div>
{% endif %}
{% if active_entry.total_break_duration > 0 %}
<div class="info-row">
<span class="info-label">Total breaks:</span>
<span class="info-value">{{ active_entry.total_break_duration|format_duration }}</span>
</div>
{% endif %}
</div>
<div class="timer-actions">
<button id="pause-btn" class="btn {% if active_entry.is_paused %}btn-success{% else %}btn-warning{% endif %}"
data-id="{{ active_entry.id }}">
{% if active_entry.is_paused %}
<span class="icon">▶️</span>
Resume Work
{% else %}
<span class="icon">⏸️</span>
Take Break
{% endif %}
</button>
<button id="leave-btn" class="btn btn-danger" data-id="{{ active_entry.id }}">
<span class="icon">⏹️</span>
Stop Working
</button>
</div>
</div>
{% else %}
<!-- Inactive Timer -->
<div class="timer-card inactive">
<div class="start-work-container">
<h2>Start Tracking Time</h2>
<p>Select a project and task to begin tracking your work</p>
<form id="start-work-form" class="modern-form">
<div class="form-row">
<div class="form-group">
<label for="project-select" class="form-label">
Project <span class="optional-badge">Optional</span>
</label>
<select id="project-select" name="project_id" class="form-control">
<option value="">No specific project</option>
{% for project in available_projects %}
<option value="{{ project.id }}" data-color="{{ project.color or '#667eea' }}">
{{ project.code }} - {{ project.name }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="task-select" class="form-label">
Task <span class="optional-badge">Optional</span>
</label>
<select id="task-select" name="task_id" class="form-control" disabled>
<option value="">Select a project first</option>
</select>
</div>
</div>
<div class="form-group">
<label for="work-notes" class="form-label">
Notes <span class="optional-badge">Optional</span>
</label>
<textarea id="work-notes" name="notes" class="form-control"
rows="2" placeholder="What are you working on?"></textarea>
</div>
<div class="form-actions">
<button type="submit" id="arrive-btn" class="btn btn-primary btn-large">
<span class="icon">▶️</span>
Start Working
</button>
</div>
</form>
</div>
</div>
{% endif %}
</div>
<!-- Quick Stats -->
{% if today_hours is defined %}
<div class="stats-section">
<div class="stat-card">
<div class="stat-value">{{ today_hours|format_duration }}</div>
<div class="stat-label">Today</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ week_hours|format_duration }}</div>
<div class="stat-label">This Week</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ month_hours|format_duration }}</div>
<div class="stat-label">This Month</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ active_projects|length if active_projects else 0 }}</div>
<div class="stat-label">Active Projects</div>
</div>
</div>
{% endif %}
<!-- Recent Entries -->
<div class="entries-section">
<div class="section-header">
<h2 class="section-title">
<span class="icon">📋</span>
Recent Time Entries
</h2>
<div class="view-toggle">
<button class="toggle-btn active" data-view="list">
<span class="icon">📝</span>
List
</button>
<button class="toggle-btn" data-view="grid">
<span class="icon">📊</span>
Grid
</button>
</div>
</div>
<!-- List View -->
<div id="list-view" class="view-container active">
{% if history %}
<div class="entries-table-container">
<table class="entries-table">
<thead>
<tr>
<th>Date</th>
<th>Time</th>
<th>Project / Task</th>
<th>Duration</th>
<th>Break</th>
<th>Notes</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for entry in history %}
<tr data-entry-id="{{ entry.id }}" class="entry-row">
<td>
<div class="date-cell">
<span class="date-day">{{ entry.arrival_time.strftime('%d') }}</span>
<span class="date-month">{{ entry.arrival_time.strftime('%b') }}</span>
</div>
</td>
<td>
<div class="time-cell">
<span class="time-start">{{ entry.arrival_time|format_time }}</span>
<span class="time-separator"></span>
<span class="time-end">{{ entry.departure_time|format_time if entry.departure_time else 'Active' }}</span>
</div>
</td>
<td>
<div class="project-task-cell">
{% if entry.project %}
<span class="project-tag" style="background-color: {{ entry.project.color or '#667eea' }}">
{{ entry.project.code }}
</span>
{% endif %}
{% if entry.task %}
<span class="task-name">{{ entry.task.title }}</span>
{% elif entry.project %}
<span class="project-name">{{ entry.project.name }}</span>
{% else %}
<span class="no-project">No project</span>
{% endif %}
</div>
</td>
<td>
<span class="duration-badge">
{{ entry.duration|format_duration if entry.duration is not none else 'In progress' }}
</span>
</td>
<td>
<span class="break-duration">
{{ entry.total_break_duration|format_duration if entry.total_break_duration else '-' }}
</span>
</td>
<td>
<span class="notes-preview" title="{{ entry.notes or '' }}">
{{ entry.notes[:30] + '...' if entry.notes and entry.notes|length > 30 else entry.notes or '-' }}
</span>
</td>
<td>
<div class="actions-cell">
{% if entry.departure_time and not active_entry %}
<button class="btn-icon resume-work-btn" data-id="{{ entry.id }}" title="Resume">
<span class="icon">🔄</span>
</button>
{% endif %}
<button class="btn-icon edit-entry-btn" data-id="{{ entry.id }}" title="Edit">
<span class="icon">✏️</span>
</button>
<button class="btn-icon delete-entry-btn" data-id="{{ entry.id }}" title="Delete">
<span class="icon">🗑️</span>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">📭</div>
<h3>No time entries yet</h3>
<p>Start tracking your time to see entries here</p>
</div>
{% endif %}
</div>
<!-- Grid View -->
<div id="grid-view" class="view-container">
<div class="entries-grid">
{% for entry in history %}
<div class="entry-card" data-entry-id="{{ entry.id }}">
<div class="entry-header">
<div class="entry-date">
{{ entry.arrival_time.strftime('%d %b %Y') }}
</div>
<div class="entry-duration">
{{ entry.duration|format_duration if entry.duration is not none else 'Active' }}
</div>
</div>
<div class="entry-body">
{% if entry.project %}
<div class="entry-project" style="border-left-color: {{ entry.project.color or '#667eea' }}">
<strong>{{ entry.project.code }}</strong> - {{ entry.project.name }}
</div>
{% endif %}
{% if entry.task %}
<div class="entry-task">
<span class="icon">📋</span> {{ entry.task.title }}
</div>
{% endif %}
<div class="entry-time">
<span class="icon">🕐</span>
{{ entry.arrival_time|format_time }} - {{ entry.departure_time|format_time if entry.departure_time else 'Active' }}
</div>
{% if entry.notes %}
<div class="entry-notes">
<span class="icon">📝</span>
{{ entry.notes }}
</div>
{% endif %}
</div>
<div class="entry-footer">
<button class="btn-sm edit-entry-btn" data-id="{{ entry.id }}">Edit</button>
<button class="btn-sm btn-danger delete-entry-btn" data-id="{{ entry.id }}">Delete</button>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Edit Entry Modal -->
<div id="edit-modal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h3>Edit Time Entry</h3>
<form id="edit-entry-form">
<input type="hidden" id="edit-entry-id">
<div class="form-group">
<label for="edit-arrival-date">Arrival Date:</label>
<input type="date" id="edit-arrival-date" required>
<small>Format: YYYY-MM-DD</small>
</div>
<div class="form-group">
<label for="edit-arrival-time">Arrival Time (24h):</label>
<input type="time" id="edit-arrival-time" required step="1">
<small>Format: HH:MM (24-hour)</small>
</div>
<div class="form-group">
<label for="edit-departure-date">Departure Date:</label>
<input type="date" id="edit-departure-date">
<small>Format: YYYY-MM-DD</small>
</div>
<div class="form-group">
<label for="edit-departure-time">Departure Time (24h):</label>
<input type="time" id="edit-departure-time" step="1">
<small>Format: HH:MM (24-hour)</small>
</div>
<button type="submit" class="btn">Save Changes</button>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="delete-modal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h3>Confirm Deletion</h3>
<p>Are you sure you want to delete this time entry? This action cannot be undone.</p>
<input type="hidden" id="delete-entry-id">
<div class="modal-actions">
<button id="confirm-delete" class="btn btn-danger">Delete</button>
<button id="cancel-delete" class="btn">Cancel</button>
</div>
</div>
</div>
<!-- Manual Time Entry Modal -->
<div id="manual-entry-modal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h3>Add Manual Time Entry</h3>
<form id="manual-entry-form">
<div class="form-group">
<label for="manual-project-select">Project (Optional):</label>
<select id="manual-project-select" name="project_id">
<option value="">No specific project</option>
{% for project in available_projects %}
<option value="{{ project.id }}">{{ project.code }} - {{ project.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="manual-start-date">Start Date:</label>
<input type="date" id="manual-start-date" required>
</div>
<div class="form-group">
<label for="manual-start-time">Start Time:</label>
<input type="time" id="manual-start-time" required step="1">
</div>
<div class="form-group">
<label for="manual-end-date">End Date:</label>
<input type="date" id="manual-end-date" required>
</div>
<div class="form-group">
<label for="manual-end-time">End Time:</label>
<input type="time" id="manual-end-time" required step="1">
</div>
<div class="form-group">
<label for="manual-break-minutes">Break Duration (minutes):</label>
<input type="number" id="manual-break-minutes" min="0" value="0" placeholder="Break time in minutes">
</div>
<div class="form-group">
<label for="manual-notes">Notes (Optional):</label>
<textarea id="manual-notes" name="notes" rows="3" placeholder="Description of work performed"></textarea>
</div>
<button type="submit" class="btn">Add Entry</button>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Manual entry functionality
document.getElementById('manual-entry-btn').addEventListener('click', function() {
// Set default dates to today
const today = new Date().toISOString().split('T')[0];
document.getElementById('manual-start-date').value = today;
document.getElementById('manual-end-date').value = today;
document.getElementById('manual-entry-modal').style.display = 'block';
});
// Manual entry form submission
document.getElementById('manual-entry-form').addEventListener('submit', function(e) {
e.preventDefault();
const projectId = document.getElementById('manual-project-select').value || null;
const startDate = document.getElementById('manual-start-date').value;
const startTime = document.getElementById('manual-start-time').value;
const endDate = document.getElementById('manual-end-date').value;
const endTime = document.getElementById('manual-end-time').value;
const breakMinutes = parseInt(document.getElementById('manual-break-minutes').value) || 0;
const notes = document.getElementById('manual-notes').value;
// Validate end time is after start time
const startDateTime = new Date(`${startDate}T${startTime}`);
const endDateTime = new Date(`${endDate}T${endTime}`);
if (endDateTime <= startDateTime) {
alert('End time must be after start time');
return;
}
// Send request to create manual entry
fetch('/api/manual-entry', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
project_id: projectId,
start_date: startDate,
start_time: startTime,
end_date: endDate,
end_time: endTime,
break_minutes: breakMinutes,
notes: notes
}),
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('manual-entry-modal').style.display = 'none';
location.reload(); // Refresh to show new entry
} else {
alert('Error: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while adding the manual entry');
});
});
// Edit entry functionality
document.querySelectorAll('.edit-entry-btn').forEach(button => {
button.addEventListener('click', function() {
const entryId = this.getAttribute('data-id');
const row = document.querySelector(`tr[data-entry-id="${entryId}"]`);
const cells = row.querySelectorAll('td');
// Get date and time from the row
const dateStr = cells[0].textContent.trim();
const arrivalTimeStr = cells[2].textContent.trim(); // arrival time is now in column 2
const departureTimeStr = cells[3].textContent.trim(); // departure time is now in column 3
// Set values in the form
document.getElementById('edit-entry-id').value = entryId;
document.getElementById('edit-arrival-date').value = dateStr;
// Format time for input (HH:MM format)
document.getElementById('edit-arrival-time').value = arrivalTimeStr.substring(0, 5);
if (departureTimeStr && departureTimeStr !== 'Active') {
document.getElementById('edit-departure-date').value = dateStr;
document.getElementById('edit-departure-time').value = departureTimeStr.substring(0, 5);
} else {
document.getElementById('edit-departure-date').value = '';
document.getElementById('edit-departure-time').value = '';
}
// Show the modal
document.getElementById('edit-modal').style.display = 'block';
});
});
// Delete entry functionality
document.querySelectorAll('.delete-entry-btn').forEach(button => {
button.addEventListener('click', function() {
const entryId = this.getAttribute('data-id');
document.getElementById('delete-entry-id').value = entryId;
document.getElementById('delete-modal').style.display = 'block';
});
});
// Close modals when clicking the X
document.querySelectorAll('.close').forEach(closeBtn => {
closeBtn.addEventListener('click', function() {
this.closest('.modal').style.display = 'none';
});
});
// Close modals when clicking outside
window.addEventListener('click', function(event) {
if (event.target.classList.contains('modal')) {
event.target.style.display = 'none';
}
});
// Cancel delete
document.getElementById('cancel-delete').addEventListener('click', function() {
document.getElementById('delete-modal').style.display = 'none';
});
// Confirm delete
document.getElementById('confirm-delete').addEventListener('click', function() {
const entryId = document.getElementById('delete-entry-id').value;
fetch(`/api/delete/${entryId}`, {
method: 'DELETE',
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Remove the row from the table
document.querySelector(`tr[data-entry-id="${entryId}"]`).remove();
// Close the modal
document.getElementById('delete-modal').style.display = 'none';
// Show success message
alert('Entry deleted successfully');
} else {
alert('Error: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while deleting the entry');
});
});
// Submit edit form
document.getElementById('edit-entry-form').addEventListener('submit', function(e) {
e.preventDefault();
const entryId = document.getElementById('edit-entry-id').value;
const arrivalDate = document.getElementById('edit-arrival-date').value;
const arrivalTime = document.getElementById('edit-arrival-time').value;
const departureDate = document.getElementById('edit-departure-date').value || '';
const departureTime = document.getElementById('edit-departure-time').value || '';
// Ensure we have seconds in the time strings
const arrivalTimeWithSeconds = arrivalTime.includes(':') ?
(arrivalTime.split(':').length === 2 ? arrivalTime + ':00' : arrivalTime) :
arrivalTime + ':00:00';
// Format datetime strings for the API (ISO 8601: YYYY-MM-DDTHH:MM:SS)
const arrivalDateTime = `${arrivalDate}T${arrivalTimeWithSeconds}`;
let departureDateTime = null;
if (departureDate && departureTime) {
const departureTimeWithSeconds = departureTime.includes(':') ?
(departureTime.split(':').length === 2 ? departureTime + ':00' : departureTime) :
departureTime + ':00:00';
departureDateTime = `${departureDate}T${departureTimeWithSeconds}`;
}
console.log('Sending update:', {
arrival_time: arrivalDateTime,
departure_time: departureDateTime
});
// Send update request
fetch(`/api/update/${entryId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
arrival_time: arrivalDateTime,
departure_time: departureDateTime
}),
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.message || 'Server error');
});
}
return response.json();
})
.then(data => {
if (data.success) {
// Close the modal
document.getElementById('edit-modal').style.display = 'none';
// Refresh the page to show updated data
location.reload();
} else {
alert('Error: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while updating the entry: ' + error.message);
});
});
});
</script>
<style>
.start-work-form {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.start-work-form .form-group {
margin-bottom: 1rem;
}
.start-work-form label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.start-work-form select,
.start-work-form textarea {
width: 100%;
padding: 0.75rem;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s ease;
}
.start-work-form select:focus,
.start-work-form textarea:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
}
.project-info {
color: #4CAF50;
font-size: 0.9rem;
margin-top: 0.5rem;
}
.project-tag {
background: #4CAF50;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
margin-right: 0.5rem;
}
.project-tag + small {
color: #666;
font-size: 0.85rem;
}
.time-history td {
vertical-align: middle;
}
.time-history .project-tag + small {
display: block;
margin-top: 0.25rem;
}
.manual-entry-btn {
background: #17a2b8;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
margin-left: 1rem;
transition: background-color 0.2s ease;
}
.manual-entry-btn:hover {
background: #138496;
}
.modal {
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.4);
}
.modal-content {
background-color: #fefefe;
margin: 5% auto;
padding: 20px;
border: 1px solid #888;
border-radius: 8px;
width: 500px;
max-width: 90%;
max-height: 80%;
overflow-y: auto;
}
.modal .form-group {
margin-bottom: 1rem;
}
.modal label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.modal input,
.modal select,
.modal textarea {
width: 100%;
padding: 0.75rem;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 1rem;
box-sizing: border-box;
}
.modal input:focus,
.modal select:focus,
.modal textarea:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
}
</style>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,473 @@
{% extends "layout.html" %}
{% block content %}
<div class="invitations-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon">📨</span>
Invitations
</h1>
<p class="page-subtitle">Manage team invitations for {{ g.user.company.name }}</p>
</div>
<div class="header-actions">
<a href="{{ url_for('invitations.send_invitation') }}" class="btn btn-primary">
<span class="icon">+</span>
Send New Invitation
</a>
</div>
</div>
</div>
<!-- Statistics -->
<div class="stats-section">
<div class="stat-card">
<div class="stat-value">{{ pending_invitations|length }}</div>
<div class="stat-label">Pending</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ accepted_invitations|length }}</div>
<div class="stat-label">Accepted</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ expired_invitations|length }}</div>
<div class="stat-label">Expired</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ (pending_invitations|length + accepted_invitations|length + expired_invitations|length) }}</div>
<div class="stat-label">Total Sent</div>
</div>
</div>
<!-- Pending Invitations -->
{% if pending_invitations %}
<div class="section">
<h2 class="section-title">
<span class="icon"></span>
Pending Invitations
</h2>
<div class="invitations-list">
{% for invitation in pending_invitations %}
<div class="invitation-card pending">
<div class="invitation-header">
<div class="invitation-info">
<h3 class="invitation-email">{{ invitation.email }}</h3>
<div class="invitation-meta">
<span class="meta-item">
<span class="icon">👤</span>
Role: {{ invitation.role }}
</span>
<span class="meta-item">
<span class="icon">📅</span>
Sent {{ invitation.created_at.strftime('%b %d, %Y') }}
</span>
<span class="meta-item">
<span class="icon"></span>
Expires {{ invitation.expires_at.strftime('%b %d, %Y') }}
</span>
</div>
</div>
<div class="invitation-actions">
<form method="POST" action="{{ url_for('invitations.resend_invitation', invitation_id=invitation.id) }}" style="display: inline;">
<button type="submit" class="btn btn-sm btn-secondary">
<span class="icon">🔄</span>
Resend
</button>
</form>
<form method="POST" action="{{ url_for('invitations.revoke_invitation', invitation_id=invitation.id) }}" style="display: inline;">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Are you sure you want to revoke this invitation?');">
<span class="icon"></span>
Revoke
</button>
</form>
</div>
</div>
<div class="invitation-footer">
<span class="footer-text">Invited by {{ invitation.invited_by.username }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Accepted Invitations -->
{% if accepted_invitations %}
<div class="section">
<h2 class="section-title">
<span class="icon"></span>
Accepted Invitations
</h2>
<div class="invitations-list">
{% for invitation in accepted_invitations %}
<div class="invitation-card accepted">
<div class="invitation-header">
<div class="invitation-info">
<h3 class="invitation-email">{{ invitation.email }}</h3>
<div class="invitation-meta">
<span class="meta-item">
<span class="icon">👤</span>
Joined as: {{ invitation.accepted_by.username }} ({{ invitation.role }})
</span>
<span class="meta-item">
<span class="icon">📅</span>
Accepted {{ invitation.accepted_at.strftime('%b %d, %Y') }}
</span>
</div>
</div>
<div class="invitation-actions">
<a href="{{ url_for('users.view_user', user_id=invitation.accepted_by.id) }}" class="btn btn-sm btn-secondary">
<span class="icon">👁️</span>
View User
</a>
</div>
</div>
<div class="invitation-footer">
<span class="footer-text">Invited by {{ invitation.invited_by.username }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Expired Invitations -->
{% if expired_invitations %}
<div class="section">
<h2 class="section-title">
<span class="icon">⏱️</span>
Expired Invitations
</h2>
<div class="invitations-list">
{% for invitation in expired_invitations %}
<div class="invitation-card expired">
<div class="invitation-header">
<div class="invitation-info">
<h3 class="invitation-email">{{ invitation.email }}</h3>
<div class="invitation-meta">
<span class="meta-item">
<span class="icon">👤</span>
Role: {{ invitation.role }}
</span>
<span class="meta-item">
<span class="icon">📅</span>
Expired {{ invitation.expires_at.strftime('%b %d, %Y') }}
</span>
</div>
</div>
<div class="invitation-actions">
<form method="POST" action="{{ url_for('invitations.resend_invitation', invitation_id=invitation.id) }}" style="display: inline;">
<button type="submit" class="btn btn-sm btn-primary">
<span class="icon">📤</span>
Send New Invitation
</button>
</form>
</div>
</div>
<div class="invitation-footer">
<span class="footer-text">Originally invited by {{ invitation.invited_by.username }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Empty State -->
{% if not pending_invitations and not accepted_invitations and not expired_invitations %}
<div class="empty-state">
<div class="empty-icon">📨</div>
<h3>No invitations yet</h3>
<p>Start building your team by sending invitations</p>
<a href="{{ url_for('invitations.send_invitation') }}" class="btn btn-primary">
<span class="icon">+</span>
Send First Invitation
</a>
</div>
{% endif %}
</div>
<style>
.invitations-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
/* Header styles - reuse from other pages */
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 2rem;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-icon {
font-size: 2.5rem;
display: inline-block;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.page-subtitle {
font-size: 1.1rem;
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
/* Stats section */
.stats-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 12px;
text-align: center;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: #667eea;
}
.stat-label {
font-size: 0.9rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
/* Sections */
.section {
margin-bottom: 2rem;
}
.section-title {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Invitation cards */
.invitations-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.invitation-card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
overflow: hidden;
transition: all 0.3s ease;
}
.invitation-card.pending {
border-left: 4px solid #f59e0b;
}
.invitation-card.accepted {
border-left: 4px solid #10b981;
}
.invitation-card.expired {
border-left: 4px solid #ef4444;
}
.invitation-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.invitation-header {
padding: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.invitation-email {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 0.5rem 0;
}
.invitation-meta {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: #6b7280;
}
.invitation-actions {
display: flex;
gap: 0.5rem;
}
.invitation-footer {
background: #f8f9fa;
padding: 0.75rem 1.5rem;
border-top: 1px solid #e5e7eb;
}
.footer-text {
font-size: 0.875rem;
color: #6b7280;
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-sm {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: white;
color: #4b5563;
border: 2px solid #e5e7eb;
}
.btn-secondary:hover {
background: #f3f4f6;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
/* Empty state */
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.empty-state h3 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.5rem;
}
.empty-state p {
color: #6b7280;
margin-bottom: 2rem;
}
/* Responsive */
@media (max-width: 768px) {
.invitations-container {
padding: 1rem;
}
.invitation-header {
flex-direction: column;
align-items: flex-start;
}
.invitation-actions {
width: 100%;
justify-content: flex-start;
}
.meta-item {
font-size: 0.8rem;
}
}
</style>
{% endblock %}

View File

@@ -0,0 +1,378 @@
{% extends "layout.html" %}
{% block content %}
<div class="invitation-send-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon">✉️</span>
Send Invitation
</h1>
<p class="page-subtitle">Invite team members to join {{ g.user.company.name }}</p>
</div>
<div class="header-actions">
<a href="{{ url_for('invitations.list_invitations') }}" class="btn btn-secondary">
<span class="icon"></span>
Back to Invitations
</a>
</div>
</div>
</div>
<!-- Main Content -->
<div class="content-wrapper">
<div class="card invitation-form-card">
<div class="card-header">
<h2 class="card-title">
<span class="icon">👥</span>
Invitation Details
</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('invitations.send_invitation') }}" class="modern-form">
<div class="form-group">
<label for="email" class="form-label">Email Address</label>
<input type="email" id="email" name="email" class="form-control"
placeholder="colleague@example.com" required autofocus>
<span class="form-hint">The email address where the invitation will be sent</span>
</div>
<div class="form-group">
<label for="role" class="form-label">Role</label>
<select id="role" name="role" class="form-control">
{% for role in roles %}
<option value="{{ role }}" {% if role == 'Team Member' %}selected{% endif %}>
{{ role }}
</option>
{% endfor %}
</select>
<span class="form-hint">The role this user will have when they join</span>
</div>
<div class="form-group">
<label for="custom_message" class="form-label">Personal Message (Optional)</label>
<textarea id="custom_message" name="custom_message" class="form-control"
rows="4" placeholder="Add a personal message to the invitation..."></textarea>
<span class="form-hint">This message will be included in the invitation email</span>
</div>
<div class="info-panel">
<div class="info-item">
<span class="info-icon">📧</span>
<div class="info-content">
<h4>What happens next?</h4>
<ul>
<li>An email invitation will be sent immediately</li>
<li>The recipient will have 7 days to accept</li>
<li>They'll create their account using the invitation link</li>
<li>They'll automatically join {{ g.user.company.name }}</li>
</ul>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<span class="icon">📤</span>
Send Invitation
</button>
<a href="{{ url_for('invitations.list_invitations') }}" class="btn btn-ghost">
Cancel
</a>
</div>
</form>
</div>
</div>
<!-- Preview Section -->
<div class="card preview-card">
<div class="card-header">
<h2 class="card-title">
<span class="icon">👁️</span>
Email Preview
</h2>
</div>
<div class="card-body">
<div class="email-preview">
<div class="preview-from">
<strong>From:</strong> {{ g.branding.app_name }} &lt;{{ g.branding.email_from or 'noreply@timetrack.com' }}&gt;
</div>
<div class="preview-to">
<strong>To:</strong> <span id="preview-email">colleague@example.com</span>
</div>
<div class="preview-subject">
<strong>Subject:</strong> Invitation to join {{ g.user.company.name }} on {{ g.branding.app_name }}
</div>
<div class="preview-body">
<p>Hello,</p>
<p>{{ g.user.username }} has invited you to join {{ g.user.company.name }} on {{ g.branding.app_name }}.</p>
<p id="preview-message" style="display: none;"></p>
<p>Click the link below to accept the invitation and create your account:</p>
<p><a href="#" class="preview-link">[Invitation Link]</a></p>
<p>This invitation will expire in 7 days.</p>
<p>Best regards,<br>The {{ g.branding.app_name }} Team</p>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.invitation-send-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.content-wrapper {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-top: 2rem;
}
@media (max-width: 1024px) {
.content-wrapper {
grid-template-columns: 1fr;
}
}
.invitation-form-card,
.preview-card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
}
.email-preview {
background: #f8f9fa;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.5rem;
font-family: system-ui, -apple-system, sans-serif;
}
.email-preview > div {
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #e5e7eb;
}
.preview-body {
border-bottom: none !important;
margin-top: 1rem;
}
.preview-link {
color: #667eea;
text-decoration: underline;
}
/* Reuse existing styles */
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 2rem;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-icon {
font-size: 2.5rem;
display: inline-block;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.page-subtitle {
font-size: 1.1rem;
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
.card-header {
background: #f8f9fa;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: #1f2937;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-body {
padding: 1.5rem;
}
.modern-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-weight: 600;
color: #374151;
font-size: 0.95rem;
}
.form-control {
padding: 0.75rem 1rem;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s ease;
background-color: #f9fafb;
}
.form-control:focus {
outline: none;
border-color: #667eea;
background-color: white;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-hint {
font-size: 0.875rem;
color: #6b7280;
}
.info-panel {
background: #f3f4f6;
border-radius: 8px;
padding: 1.5rem;
}
.info-item {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.info-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.info-content h4 {
font-size: 1rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 0.5rem 0;
}
.info-content ul {
margin: 0;
padding-left: 1.25rem;
}
.info-content li {
margin-bottom: 0.25rem;
color: #4b5563;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: white;
color: #4b5563;
border: 2px solid #e5e7eb;
}
.btn-secondary:hover {
background: #f3f4f6;
}
.btn-ghost {
background: transparent;
color: #6b7280;
}
.btn-ghost:hover {
color: #374151;
background: #f3f4f6;
}
</style>
<script>
// Live preview updates
document.getElementById('email').addEventListener('input', function(e) {
document.getElementById('preview-email').textContent = e.target.value || 'colleague@example.com';
});
document.getElementById('custom_message').addEventListener('input', function(e) {
const previewMessage = document.getElementById('preview-message');
if (e.target.value.trim()) {
previewMessage.textContent = e.target.value;
previewMessage.style.display = 'block';
} else {
previewMessage.style.display = 'none';
}
});
</script>
{% endblock %}

View File

@@ -129,32 +129,31 @@
<ul>
{% if g.user %}
<li><a href="{{ url_for('home') }}" data-tooltip="Home"><i class="nav-icon">🏠</i><span class="nav-text">Home</span></a></li>
<li><a href="{{ url_for('time_tracking') }}" data-tooltip="Time Tracking"><i class="nav-icon">⏱️</i><span class="nav-text">Time Tracking</span></a></li>
<li><a href="{{ url_for('dashboard') }}" data-tooltip="Dashboard"><i class="nav-icon">📊</i><span class="nav-text">Dashboard</span></a></li>
<li><a href="{{ url_for('unified_task_management') }}" data-tooltip="Task Management"><i class="nav-icon">📋</i><span class="nav-text">Task Management</span></a></li>
<li><a href="{{ url_for('sprint_management') }}" data-tooltip="Sprint Management"><i class="nav-icon">🏃‍♂️</i><span class="nav-text">Sprints</span></a></li>
<li><a href="{{ url_for('tasks.unified_task_management') }}" data-tooltip="Task Management"><i class="nav-icon">📋</i><span class="nav-text">Task Management</span></a></li>
<li><a href="{{ url_for('sprints.sprint_management') }}" data-tooltip="Sprint Management"><i class="nav-icon">🏃‍♂️</i><span class="nav-text">Sprints</span></a></li>
<li><a href="{{ url_for('notes.notes_list') }}" data-tooltip="Notes"><i class="nav-icon">📝</i><span class="nav-text">Notes</span></a></li>
<li><a href="{{ url_for('analytics') }}" data-tooltip="Time Analytics"><i class="nav-icon">📊</i><span class="nav-text">Analytics</span></a></li>
<!-- Role-based menu items -->
{% if g.user.role == Role.ADMIN or g.user.role == Role.SYSTEM_ADMIN %}
<li class="nav-divider">Admin</li>
<li><a href="{{ url_for('dashboard') }}" data-tooltip="Dashboard"><i class="nav-icon">📈</i><span class="nav-text">Dashboard</span></a></li>
<li><a href="{{ url_for('admin_company') }}" data-tooltip="Company"><i class="nav-icon">🏢</i><span class="nav-text">Company</span></a></li>
<li><a href="{{ url_for('admin_users') }}" data-tooltip="Manage Users"><i class="nav-icon">👥</i><span class="nav-text">Manage Users</span></a></li>
<li><a href="{{ url_for('admin_teams') }}" data-tooltip="Manage Teams"><i class="nav-icon">🏭</i><span class="nav-text">Manage Teams</span></a></li>
<li><a href="{{ url_for('admin_projects') }}" data-tooltip="Manage Projects"><i class="nav-icon">📝</i><span class="nav-text">Manage Projects</span></a></li>
<li><a href="{{ url_for('admin_work_policies') }}" data-tooltip="Work Policies"><i class="nav-icon">⚖️</i><span class="nav-text">Work Policies</span></a></li>
<li><a href="{{ url_for('admin_settings') }}" data-tooltip="System Settings"><i class="nav-icon">🔧</i><span class="nav-text">System Settings</span></a></li>
<li><a href="{{ url_for('companies.admin_company') }}" data-tooltip="Company Settings"><i class="nav-icon">🏢</i><span class="nav-text">Company Settings</span></a></li>
<li><a href="{{ url_for('users.admin_users') }}" data-tooltip="Manage Users"><i class="nav-icon">👥</i><span class="nav-text">Manage Users</span></a></li>
<li><a href="{{ url_for('invitations.list_invitations') }}" data-tooltip="Invitations"><i class="nav-icon">📨</i><span class="nav-text">Invitations</span></a></li>
<li><a href="{{ url_for('teams.admin_teams') }}" data-tooltip="Manage Teams"><i class="nav-icon">🏭</i><span class="nav-text">Manage Teams</span></a></li>
<li><a href="{{ url_for('projects.admin_projects') }}" data-tooltip="Manage Projects"><i class="nav-icon">📝</i><span class="nav-text">Manage Projects</span></a></li>
{% if g.user.role == Role.SYSTEM_ADMIN %}
<li class="nav-divider">System Admin</li>
<li><a href="{{ url_for('system_admin_dashboard') }}" data-tooltip="System Dashboard"><i class="nav-icon">🌐</i><span class="nav-text">System Dashboard</span></a></li>
<li><a href="{{ url_for('system_admin_announcements') }}" data-tooltip="Announcements"><i class="nav-icon">📢</i><span class="nav-text">Announcements</span></a></li>
<li><a href="{{ url_for('system_admin.system_admin_dashboard') }}" data-tooltip="System Dashboard"><i class="nav-icon">🌐</i><span class="nav-text">System Dashboard</span></a></li>
<li><a href="{{ url_for('announcements.index') }}" data-tooltip="Announcements"><i class="nav-icon">📢</i><span class="nav-text">Announcements</span></a></li>
{% endif %}
{% elif g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %}
<li class="nav-divider">Team</li>
<li><a href="{{ url_for('dashboard') }}" data-tooltip="Dashboard"><i class="nav-icon">📈</i><span class="nav-text">Dashboard</span></a></li>
{% if g.user.role == Role.SUPERVISOR %}
<li><a href="{{ url_for('admin_projects') }}" data-tooltip="Manage Projects"><i class="nav-icon">📝</i><span class="nav-text">Manage Projects</span></a></li>
<li><a href="{{ url_for('projects.admin_projects') }}" data-tooltip="Manage Projects"><i class="nav-icon">📝</i><span class="nav-text">Manage Projects</span></a></li>
{% endif %}
{% endif %}
{% else %}

View File

@@ -14,7 +14,7 @@
</div>
<div class="page-actions task-actions">
<button id="create-task-btn" class="btn btn-success">Create New Task</button>
<a href="{{ url_for('admin_projects') }}" class="btn btn-secondary">Back to Projects</a>
<a href="{{ url_for('projects.admin_projects') }}" class="btn btn-secondary">Back to Projects</a>
</div>
</div>

View File

@@ -1,96 +0,0 @@
{% extends 'layout.html' %}
{% block content %}
<div class="container">
<h1>Manage Team: {{ team.name }}</h1>
<div class="card mb-4">
<div class="card-header">
<h2>Team Details</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('manage_team', team_id=team.id) }}">
<input type="hidden" name="action" value="update_team">
<div class="mb-3">
<label for="name" class="form-label">Team Name</label>
<input type="text" class="form-control" id="name" name="name" value="{{ team.name }}" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="3">{{ team.description }}</textarea>
</div>
<button type="submit" class="btn btn-primary">Update Team</button>
</form>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h2>Team Members</h2>
</div>
<div class="card-body">
{% if team_members %}
<table class="table table-striped">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for member in team_members %}
<tr>
<td>{{ member.username }}</td>
<td>{{ member.email }}</td>
<td>{{ member.role.value }}</td>
<td>
<form method="POST" action="{{ url_for('manage_team', team_id=team.id) }}" class="d-inline">
<input type="hidden" name="action" value="remove_member">
<input type="hidden" name="user_id" value="{{ member.id }}">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Are you sure you want to remove this user from the team?')">
Remove
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No members in this team yet.</p>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-header">
<h2>Add Team Member</h2>
</div>
<div class="card-body">
{% if available_users %}
<form method="POST" action="{{ url_for('manage_team', team_id=team.id) }}">
<input type="hidden" name="action" value="add_member">
<div class="mb-3">
<label for="user_id" class="form-label">Select User</label>
<select class="form-select" id="user_id" name="user_id" required>
<option value="">-- Select User --</option>
{% for user in available_users %}
<option value="{{ user.id }}">{{ user.username }} ({{ user.email }})</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-success">Add to Team</button>
</form>
{% else %}
<p>No available users to add to this team.</p>
{% endif %}
</div>
</div>
<div class="mt-3">
<a href="{{ url_for('admin_teams') }}" class="btn btn-secondary">Back to Teams</a>
</div>
</div>
{% endblock %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -7,12 +7,114 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}">
<style>
.registration-type {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.type-card {
flex: 1;
padding: 1.5rem;
border: 2px solid #e5e7eb;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
}
.type-card:hover {
border-color: #667eea;
background: #f8f9fa;
}
.type-card.active {
border-color: #667eea;
background: linear-gradient(135deg, #667eea10 0%, #764ba210 100%);
}
.type-card .icon {
font-size: 2.5rem;
margin-bottom: 0.5rem;
display: block;
}
.type-card h3 {
margin: 0 0 0.5rem 0;
color: #1f2937;
}
.type-card p {
margin: 0;
font-size: 0.875rem;
color: #6b7280;
}
.form-section {
display: none;
}
.form-section.active {
display: block;
}
.company-code-group {
background: #f8f9fa;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.optional-badge {
background: #667eea;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
margin-left: 0.5rem;
}
.benefits-list {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-top: 1.5rem;
}
.benefits-list h4 {
margin: 0 0 1rem 0;
color: #1f2937;
}
.benefits-list ul {
list-style: none;
padding: 0;
margin: 0;
}
.benefits-list li {
padding: 0.5rem 0;
padding-left: 1.5rem;
position: relative;
}
.benefits-list li:before {
content: "✓";
position: absolute;
left: 0;
color: #10b981;
font-weight: bold;
}
</style>
</head>
<body class="auth-page">
<div class="auth-container">
<div class="auth-brand">
<h1>Welcome to {{ g.branding.app_name if g.branding else 'TimeTrack' }}</h1>
<p>Join your company team</p>
<p>Create your account to start tracking time</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
@@ -23,22 +125,51 @@
{% 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>
<form method="POST" action="{{ url_for('register') }}" class="auth-form" id="registrationForm">
<!-- Registration Type Selection -->
<div class="registration-type">
<div class="type-card active" data-type="company" onclick="selectRegistrationType('company')">
<span class="icon">🏢</span>
<h3>Company Employee</h3>
<p>Join an existing company</p>
</div>
<div class="type-card" data-type="freelancer" onclick="selectRegistrationType('freelancer')">
<span class="icon">💼</span>
<h3>Freelancer</h3>
<p>Create personal workspace</p>
</div>
</div>
<form method="POST" action="{{ url_for('register') }}" class="auth-form">
<div class="form-group company-code-group">
<label for="company_code">Company Code</label>
<input type="text" id="company_code" name="company_code" class="form-control" required autofocus
placeholder="ENTER-CODE">
<small class="form-text text-muted">Get this code from your company administrator</small>
<input type="hidden" name="registration_type" id="registration_type" value="company">
<!-- Company Registration Fields -->
<div class="form-section active" id="company-section">
<div class="company-code-group">
<label for="company_code">
Company Code
<span class="optional-badge">Optional</span>
</label>
<input type="text" id="company_code" name="company_code" class="form-control"
placeholder="Enter code or leave blank to create new company">
<small class="form-text text-muted">
Have a company code? Enter it here. No code? Leave blank to create your own company.
<br><strong>Tip:</strong> Ask your admin for an email invitation instead.
</small>
</div>
</div>
<!-- Freelancer Registration Fields -->
<div class="form-section" id="freelancer-section">
<div class="form-group input-icon">
<i>🏢</i>
<input type="text" id="business_name" name="business_name" class="form-control"
placeholder="Your Business Name (optional)">
<label for="business_name">Business Name</label>
<small class="form-text text-muted">Leave blank to use your username as workspace name</small>
</div>
</div>
<!-- Common Fields -->
<div class="form-group input-icon">
<i>👤</i>
<input type="text" id="username" name="username" class="form-control" placeholder="Choose a username" required>
@@ -79,13 +210,67 @@
<p>Already have an account? <a href="{{ url_for('login') }}">Login here</a></p>
</div>
<!-- Dynamic Benefits Section -->
<div class="benefits-list" id="company-benefits">
<h4>What you get:</h4>
<ul>
<li>Join an existing company team or create your own</li>
<li>Collaborate with team members</li>
<li>Track time on company projects</li>
<li>Team management tools (if admin)</li>
<li>Shared reports and analytics</li>
</ul>
</div>
<div class="benefits-list" id="freelancer-benefits" style="display: none;">
<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>Complete control over your data</li>
</ul>
</div>
<div class="verification-notice">
<p>💡 You can register without an email, but we recommend adding one later for account recovery.</p>
<p>💡 You can register without an email, but we recommend adding one for account recovery.</p>
</div>
</form>
</div>
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
<script src="{{ url_for('static', filename='js/auth-animations.js') }}"></script>
<script>
function selectRegistrationType(type) {
// Update active card
document.querySelectorAll('.type-card').forEach(card => {
card.classList.remove('active');
});
document.querySelector(`[data-type="${type}"]`).classList.add('active');
// Update hidden field
document.getElementById('registration_type').value = type;
// Show/hide sections
if (type === 'company') {
document.getElementById('company-section').classList.add('active');
document.getElementById('freelancer-section').classList.remove('active');
document.getElementById('company-benefits').style.display = 'block';
document.getElementById('freelancer-benefits').style.display = 'none';
// Update form action
document.getElementById('registrationForm').action = "{{ url_for('register') }}";
} else {
document.getElementById('company-section').classList.remove('active');
document.getElementById('freelancer-section').classList.add('active');
document.getElementById('company-benefits').style.display = 'none';
document.getElementById('freelancer-benefits').style.display = 'block';
// Update form action
document.getElementById('registrationForm').action = "{{ url_for('register_freelancer') }}";
}
}
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More