Squashed commit of the following:
commit 1eeea9f83ad9230a5c1f7a75662770eaab0df837 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 21:15:41 2025 +0200 Disable resuming of old time entries. commit 3e3ec2f01cb7943622b819a19179388078ae1315 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 20:59:19 2025 +0200 Refactor db migrations. commit 15a51a569da36c6b7c9e01ab17b6fdbdee6ad994 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 19:58:04 2025 +0200 Apply new style for Time Tracking view. commit 77e5278b303e060d2b03853b06277f8aa567ae68 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 18:06:04 2025 +0200 Allow direct registrations as a Company. commit 188a8772757cbef374243d3a5f29e4440ddecabe Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 18:04:45 2025 +0200 Add email invitation feature. commit d9ebaa02aa01b518960a20dccdd5a327d82f30c6 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 17:12:32 2025 +0200 Apply common style for Company, User, Team management pages. commit 81149caf4d8fc6317e2ab1b4f022b32fc5aa6d22 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 16:44:32 2025 +0200 Move export functions to own module. commit 1a26e19338e73f8849c671471dd15cc3c1b1fe82 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 15:51:15 2025 +0200 Split up models.py. commit 61f1ccd10f721b0ff4dc1eccf30c7a1ee13f204d Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 12:05:28 2025 +0200 Move utility function into own modules. commit 84b341ed35e2c5387819a8b9f9d41eca900ae79f Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 11:44:24 2025 +0200 Refactor auth functions use. commit 923e311e3da5b26d85845c2832b73b7b17c48adb Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 11:35:52 2025 +0200 Refactor route nameing and fix bugs along the way. commit f0a5c4419c340e62a2615c60b2a9de28204d2995 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 10:34:33 2025 +0200 Fix URL endpoints in announcement template. commit b74d74542a1c8dc350749e4788a9464d067a88b5 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 09:25:53 2025 +0200 Move announcements to own module. commit 9563a28021ac46c82c04fe4649b394dbf96f92c7 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 09:16:30 2025 +0200 Combine Company view and edit templates. commit 6687c373e681d54e4deab6b2582fed5cea9aadf6 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 08:17:42 2025 +0200 Move Users, Company and System Administration to own modules. commit 8b7894a2e3eb84bb059f546648b6b9536fea724e Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 07:40:57 2025 +0200 Move Teams and Projects to own modules. commit d11bf059d99839ecf1f5d7020b8c8c8a2454c00b Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 07:09:33 2025 +0200 Move Tasks and Sprints to own modules.
This commit is contained in:
@@ -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
176
SCHEMA_CHANGES_SUMMARY.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
24
migrations/migration_list.txt
Normal file
24
migrations/migration_list.txt
Normal 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
|
||||
79
migrations/old_migrations/00_migration_summary.py
Executable file
79
migrations/old_migrations/00_migration_summary.py
Executable 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()
|
||||
@@ -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
|
||||
@@ -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):
|
||||
361
migrations/old_migrations/02_migrate_sqlite_to_postgres_fixed.py
Normal file
361
migrations/old_migrations/02_migrate_sqlite_to_postgres_fixed.py
Normal 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())
|
||||
104
migrations/old_migrations/03_add_dashboard_columns.py
Normal file
104
migrations/old_migrations/03_add_dashboard_columns.py
Normal 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()
|
||||
159
migrations/old_migrations/04_add_user_preferences_columns.py
Executable file
159
migrations/old_migrations/04_add_user_preferences_columns.py
Executable 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()
|
||||
244
migrations/old_migrations/05_fix_task_status_enum.py
Executable file
244
migrations/old_migrations/05_fix_task_status_enum.py
Executable 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()
|
||||
77
migrations/old_migrations/06_add_archived_status.py
Executable file
77
migrations/old_migrations/06_add_archived_status.py
Executable 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()
|
||||
141
migrations/old_migrations/07_fix_company_work_config_columns.py
Executable file
141
migrations/old_migrations/07_fix_company_work_config_columns.py
Executable 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()
|
||||
145
migrations/old_migrations/08_fix_work_region_enum.py
Executable file
145
migrations/old_migrations/08_fix_work_region_enum.py
Executable 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()
|
||||
78
migrations/old_migrations/09_add_germany_to_workregion.py
Executable file
78
migrations/old_migrations/09_add_germany_to_workregion.py
Executable 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()
|
||||
108
migrations/old_migrations/10_add_company_settings_columns.py
Executable file
108
migrations/old_migrations/10_add_company_settings_columns.py
Executable 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()
|
||||
188
migrations/old_migrations/11_fix_company_work_config_usage.py
Executable file
188
migrations/old_migrations/11_fix_company_work_config_usage.py
Executable 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()
|
||||
172
migrations/old_migrations/12_fix_task_status_usage.py
Executable file
172
migrations/old_migrations/12_fix_task_status_usage.py
Executable 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()
|
||||
154
migrations/old_migrations/13_fix_work_region_usage.py
Executable file
154
migrations/old_migrations/13_fix_work_region_usage.py
Executable 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()
|
||||
227
migrations/old_migrations/14_fix_removed_fields.py
Executable file
227
migrations/old_migrations/14_fix_removed_fields.py
Executable 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()
|
||||
@@ -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__":
|
||||
repair_user_roles()
|
||||
try:
|
||||
repair_user_roles()
|
||||
except Exception as e:
|
||||
logger.error(f"Migration failed: {e}")
|
||||
sys.exit(1)
|
||||
65
migrations/old_migrations/19_add_company_invitations.py
Normal file
65
migrations/old_migrations/19_add_company_invitations.py
Normal 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)
|
||||
94
migrations/old_migrations/20_add_company_updated_at.py
Executable file
94
migrations/old_migrations/20_add_company_updated_at.py
Executable 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)
|
||||
138
migrations/old_migrations/run_all_db_migrations.py
Executable file
138
migrations/old_migrations/run_all_db_migrations.py
Executable 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())
|
||||
166
migrations/old_migrations/run_code_migrations.py
Executable file
166
migrations/old_migrations/run_code_migrations.py
Executable 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())
|
||||
327
migrations/postgres_only_migration.py
Executable file
327
migrations/postgres_only_migration.py
Executable 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())
|
||||
131
migrations/run_postgres_migrations.py
Executable file
131
migrations/run_postgres_migrations.py
Executable 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())
|
||||
49
models/__init__.py
Normal file
49
models/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
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
|
||||
|
||||
# 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'
|
||||
]
|
||||
91
models/announcement.py
Normal file
91
models/announcement.py
Normal 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
20
models/base.py
Normal 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
232
models/company.py
Normal 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
99
models/dashboard.py
Normal 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
115
models/enums.py
Normal 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
56
models/invitation.py
Normal 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}>'
|
||||
86
models/project.py
Normal file
86
models/project.py
Normal 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
117
models/sprint.py
Normal 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
132
models/system.py
Normal 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
245
models/task.py
Normal 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
26
models/team.py
Normal 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
32
models/time_entry.py
Normal 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
203
models/user.py
Normal 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
31
models/work_config.py
Normal 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
189
routes/announcements.py
Normal 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'))
|
||||
100
routes/auth.py
Normal file
100
routes/auth.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Authentication decorators for route protection
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
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:
|
||||
return redirect(url_for('login', next=request.url))
|
||||
return f(*args, **kwargs)
|
||||
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"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if g.user is None:
|
||||
return redirect(url_for('login', next=request.url))
|
||||
|
||||
# System admins can access without company association
|
||||
if g.user.role == Role.SYSTEM_ADMIN:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
if g.user.company_id is None:
|
||||
flash('You must be associated with a company to access this page.', 'error')
|
||||
return redirect(url_for('setup_company'))
|
||||
|
||||
# Set company context
|
||||
g.company = Company.query.get(g.user.company_id)
|
||||
if not g.company or not g.company.is_active:
|
||||
flash('Your company is not active. Please contact support.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
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('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('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
360
routes/company.py
Normal 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
102
routes/company_api.py
Normal 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
94
routes/export.py
Normal 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
96
routes/export_api.py
Normal 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
217
routes/invitations.py
Normal 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'))
|
||||
238
routes/projects.py
Normal file
238
routes/projects.py
Normal 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
170
routes/projects_api.py
Normal 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
47
routes/sprints.py
Normal 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
224
routes/sprints_api.py
Normal 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
511
routes/system_admin.py
Normal 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
128
routes/tasks.py
Normal 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
985
routes/tasks_api.py
Normal 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
191
routes/teams.py
Normal 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
124
routes/teams_api.py
Normal 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
532
routes/users.py
Normal 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
75
routes/users_api.py
Normal 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]
|
||||
})
|
||||
64
startup.sh
64
startup.sh
@@ -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
40
startup_postgres.sh
Executable 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
|
||||
@@ -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
445
static/js/time-tracking.js
Normal 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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 %}
|
||||
{% if user.is_blocked %}
|
||||
<a href="{{ url_for('toggle_user_status', user_id=user.id) }}" class="btn btn-sm btn-success">Unblock</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('toggle_user_status', user_id=user.id) }}" class="btn btn-sm btn-warning">Block</a>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('users.toggle_user_status', user_id=user.id) }}" style="display: inline;">
|
||||
{% if user.is_blocked %}
|
||||
<button type="submit" class="btn btn-sm btn-success">Unblock</button>
|
||||
{% else %}
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
152
templates/emails/invitation.html
Normal file
152
templates/emails/invitation.html
Normal 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>© {{ g.branding.app_name }} - Time Tracking Made Simple</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
108
templates/emails/invitation_reminder.html
Normal file
108
templates/emails/invitation_reminder.html
Normal 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>© {{ g.branding.app_name }} - Time Tracking Made Simple</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
|
||||
1634
templates/index.html
1634
templates/index.html
File diff suppressed because it is too large
Load Diff
938
templates/index_old.html
Normal file
938
templates/index_old.html
Normal 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">×</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">×</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">×</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 %}
|
||||
473
templates/invitations/list.html
Normal file
473
templates/invitations/list.html
Normal 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 %}
|
||||
378
templates/invitations/send.html
Normal file
378
templates/invitations/send.html
Normal 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 }} <{{ g.branding.email_from or 'noreply@timetrack.com' }}>
|
||||
</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 %}
|
||||
@@ -129,31 +129,30 @@
|
||||
<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('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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
172
templates/register_invitation.html
Normal file
172
templates/register_invitation.html
Normal file
@@ -0,0 +1,172 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Accept Invitation - {{ g.branding.app_name if g.branding else 'TimeTrack' }}</title>
|
||||
<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>
|
||||
.invitation-info {
|
||||
background: linear-gradient(135deg, #667eea10 0%, #764ba210 100%);
|
||||
border: 2px solid #667eea;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.invitation-company {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.invitation-details {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.detail-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.welcome-message {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-message h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.welcome-message p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
</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>Complete your registration</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Invitation Info -->
|
||||
<div class="invitation-info">
|
||||
<div class="invitation-company">{{ invitation.company.name }}</div>
|
||||
<p>You've been invited to join this company</p>
|
||||
<div class="invitation-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-icon">👤</span>
|
||||
<span>Role: <strong>{{ invitation.role }}</strong></span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-icon">✉️</span>
|
||||
<span>Email: <strong>{{ invitation.email }}</strong></span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-icon">👥</span>
|
||||
<span>Invited by: <strong>{{ invitation.invited_by.username }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="welcome-message">
|
||||
<h3>Create Your Account</h3>
|
||||
<p>Choose a username and password to complete your registration</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('register_with_invitation', token=invitation.token) }}" class="auth-form">
|
||||
<div class="form-group input-icon">
|
||||
<i>👤</i>
|
||||
<input type="text" id="username" name="username" class="form-control"
|
||||
placeholder="Choose a username" required autofocus>
|
||||
<label for="username">Username</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="input-icon readonly-field">
|
||||
<i>📧</i>
|
||||
<input type="email" value="{{ invitation.email }}" class="form-control" readonly disabled>
|
||||
<label>Email Address</label>
|
||||
</div>
|
||||
<small class="form-text text-muted">This email was used for your invitation</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group input-icon">
|
||||
<i>🔒</i>
|
||||
<input type="password" id="password" name="password" class="form-control"
|
||||
placeholder="Create a strong password" required>
|
||||
<label for="password">Password</label>
|
||||
<div id="password-strength" class="password-strength"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group input-icon">
|
||||
<i>🔒</i>
|
||||
<input type="password" id="confirm_password" name="confirm_password" class="form-control"
|
||||
placeholder="Confirm your password" required>
|
||||
<label for="confirm_password">Confirm Password</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="terms" required>
|
||||
<label class="form-check-label" for="terms">
|
||||
I agree to the Terms of Service and Privacy Policy
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Create Account & Join {{ invitation.company.name }}</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-links">
|
||||
<p>Already have an account? <a href="{{ url_for('login') }}">Login here</a></p>
|
||||
</div>
|
||||
|
||||
<div class="verification-notice">
|
||||
<p>✅ Your email is pre-verified through this invitation</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>
|
||||
<style>
|
||||
.readonly-field {
|
||||
opacity: 0.7;
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.readonly-field input {
|
||||
background-color: #f3f4f6 !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
@@ -101,7 +101,7 @@
|
||||
<!-- Form Actions -->
|
||||
<div class="form-actions">
|
||||
{% if is_super_admin %}
|
||||
<a href="{{ url_for('admin_company') }}" class="btn btn-secondary">
|
||||
<a href="{{ url_for('companies.admin_company') }}" class="btn btn-secondary">
|
||||
← Back to Dashboard
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="header-section">
|
||||
<h1>{{ "Edit" if announcement else "Create" }} Announcement</h1>
|
||||
<p class="subtitle">{{ "Update" if announcement else "Create new" }} system announcement for users</p>
|
||||
<a href="{{ url_for('system_admin_announcements') }}" class="btn btn-secondary">
|
||||
<a href="{{ url_for('announcements.index') }}" class="btn btn-secondary">
|
||||
← Back to Announcements
|
||||
</a>
|
||||
</div>
|
||||
@@ -155,7 +155,7 @@
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{{ "Update" if announcement else "Create" }} Announcement
|
||||
</button>
|
||||
<a href="{{ url_for('system_admin_announcements') }}" class="btn btn-secondary">Cancel</a>
|
||||
<a href="{{ url_for('announcements.index') }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="content-header">
|
||||
<div class="header-row">
|
||||
<h1>System Announcements</h1>
|
||||
<a href="{{ url_for('system_admin_announcement_new') }}" class="btn btn-md btn-primary">
|
||||
<a href="{{ url_for('announcements.create') }}" class="btn btn-md btn-primary">
|
||||
<i class="icon">➕</i> New Announcement
|
||||
</a>
|
||||
</div>
|
||||
@@ -75,11 +75,11 @@
|
||||
<td>{{ announcement.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<a href="{{ url_for('system_admin_announcement_edit', id=announcement.id) }}"
|
||||
<a href="{{ url_for('announcements.edit', id=announcement.id) }}"
|
||||
class="btn btn-sm btn-outline-primary" title="Edit">
|
||||
✏️
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('system_admin_announcement_delete', id=announcement.id) }}"
|
||||
<form method="POST" action="{{ url_for('announcements.delete', id=announcement.id) }}"
|
||||
style="display: inline-block;"
|
||||
onsubmit="return confirm('Are you sure you want to delete this announcement?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
|
||||
@@ -99,13 +99,13 @@
|
||||
<div class="pagination-container">
|
||||
<div class="pagination">
|
||||
{% if announcements.has_prev %}
|
||||
<a href="{{ url_for('system_admin_announcements', page=announcements.prev_num) }}" class="page-link">« Previous</a>
|
||||
<a href="{{ url_for('announcements.index', page=announcements.prev_num) }}" class="page-link">« Previous</a>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in announcements.iter_pages() %}
|
||||
{% if page_num %}
|
||||
{% if page_num != announcements.page %}
|
||||
<a href="{{ url_for('system_admin_announcements', page=page_num) }}" class="page-link">{{ page_num }}</a>
|
||||
<a href="{{ url_for('announcements.index', page=page_num) }}" class="page-link">{{ page_num }}</a>
|
||||
{% else %}
|
||||
<span class="page-link current">{{ page_num }}</span>
|
||||
{% endif %}
|
||||
@@ -115,7 +115,7 @@
|
||||
{% endfor %}
|
||||
|
||||
{% if announcements.has_next %}
|
||||
<a href="{{ url_for('system_admin_announcements', page=announcements.next_num) }}" class="page-link">Next »</a>
|
||||
<a href="{{ url_for('announcements.index', page=announcements.next_num) }}" class="page-link">Next »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,7 +125,7 @@
|
||||
<div class="empty-state">
|
||||
<h3>No announcements found</h3>
|
||||
<p>Create your first announcement to communicate with users.</p>
|
||||
<a href="{{ url_for('system_admin_announcement_new') }}" class="btn btn-primary">
|
||||
<a href="{{ url_for('announcements.create') }}" class="btn btn-primary">
|
||||
Create Announcement
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="management-header">
|
||||
<h1>🎨 Branding Settings</h1>
|
||||
<div class="management-actions">
|
||||
<a href="{{ url_for('system_admin_dashboard') }}" class="btn btn-secondary">← Back to Dashboard</a>
|
||||
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">← Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
<!-- Save Button -->
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 Save Branding Settings</button>
|
||||
<a href="{{ url_for('system_admin_dashboard') }}" class="btn btn-secondary">Cancel</a>
|
||||
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
<div class="header-section">
|
||||
<h1>🏢 System Admin - All Companies</h1>
|
||||
<p class="subtitle">Manage companies across the entire system</p>
|
||||
<a href="{{ url_for('system_admin_dashboard') }}" class="btn btn-md btn-secondary">← Back to Dashboard</a>
|
||||
<div class="header-actions">
|
||||
<a href="/setup" class="btn btn-md btn-success">+ Add New Company</a>
|
||||
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-md btn-secondary">← Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Companies Table -->
|
||||
@@ -57,7 +60,7 @@
|
||||
<td>{{ company.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<a href="{{ url_for('system_admin_company_detail', company_id=company.id) }}"
|
||||
<a href="{{ url_for('system_admin.system_admin_company_detail', company_id=company.id) }}"
|
||||
class="btn btn-sm btn-primary">View Details</a>
|
||||
</div>
|
||||
</td>
|
||||
@@ -72,13 +75,13 @@
|
||||
<div class="pagination-section">
|
||||
<div class="pagination">
|
||||
{% if companies.has_prev %}
|
||||
<a href="{{ url_for('system_admin_companies', page=companies.prev_num) }}" class="page-link">← Previous</a>
|
||||
<a href="{{ url_for('system_admin.system_admin_companies', page=companies.prev_num) }}" class="page-link">← Previous</a>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in companies.iter_pages() %}
|
||||
{% if page_num %}
|
||||
{% if page_num != companies.page %}
|
||||
<a href="{{ url_for('system_admin_companies', page=page_num) }}" class="page-link">{{ page_num }}</a>
|
||||
<a href="{{ url_for('system_admin.system_admin_companies', page=page_num) }}" class="page-link">{{ page_num }}</a>
|
||||
{% else %}
|
||||
<span class="page-link current">{{ page_num }}</span>
|
||||
{% endif %}
|
||||
@@ -88,7 +91,7 @@
|
||||
{% endfor %}
|
||||
|
||||
{% if companies.has_next %}
|
||||
<a href="{{ url_for('system_admin_companies', page=companies.next_num) }}" class="page-link">Next →</a>
|
||||
<a href="{{ url_for('system_admin.system_admin_companies', page=companies.next_num) }}" class="page-link">Next →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user