Merge branch 'master' into feature-markdown-notes
This commit is contained in:
@@ -44,11 +44,11 @@ RUN mkdir -p /app/static/uploads/avatars && chmod -R 777 /app/static/uploads
|
|||||||
VOLUME /data
|
VOLUME /data
|
||||||
RUN mkdir /data && chmod 777 /data
|
RUN mkdir /data && chmod 777 /data
|
||||||
|
|
||||||
# Make startup script executable
|
# Make startup scripts executable
|
||||||
RUN chmod +x startup.sh
|
RUN chmod +x startup.sh startup_postgres.sh || true
|
||||||
|
|
||||||
# Expose the port the app runs on (though we'll use unix socket)
|
# Expose the port the app runs on (though we'll use unix socket)
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
# Use startup script for automatic migration
|
# Use PostgreSQL-only startup script
|
||||||
CMD ["./startup.sh"]
|
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:
|
# Task is remaining if:
|
||||||
# 1. It's not completed, OR
|
# 1. It's not completed, OR
|
||||||
# 2. It was completed after this date
|
# 2. It was completed after this date
|
||||||
if task.status != TaskStatus.COMPLETED:
|
if task.status != TaskStatus.DONE:
|
||||||
remaining_count += 1
|
remaining_count += 1
|
||||||
elif task.completed_date and task.completed_date > date_obj:
|
elif task.completed_date and task.completed_date > date_obj:
|
||||||
remaining_count += 1
|
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
|
import argparse
|
||||||
from datetime import datetime
|
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 to import from Flask app context if available
|
||||||
try:
|
try:
|
||||||
from app import app, db
|
from app import app, db
|
||||||
@@ -13,6 +13,9 @@ from datetime import datetime
|
|||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
import json
|
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
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -183,6 +186,15 @@ class SQLiteToPostgresMigration:
|
|||||||
data_row.append(value)
|
data_row.append(value)
|
||||||
data_rows.append(tuple(data_row))
|
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
|
# Insert data in batches
|
||||||
batch_size = 1000
|
batch_size = 1000
|
||||||
for i in range(0, len(data_rows), batch_size):
|
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
|
#!/usr/bin/env python3
|
||||||
from models import User, Role
|
"""
|
||||||
|
Repair user roles from string to enum values
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
import logging
|
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)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -44,4 +60,8 @@ def repair_user_roles():
|
|||||||
logger.info("Role repair completed")
|
logger.info("Role repair completed")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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())
|
||||||
51
models/__init__.py
Normal file
51
models/__init__.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""
|
||||||
|
Models package for TimeTrack application.
|
||||||
|
Split from monolithic models.py into domain-specific modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
# Initialize SQLAlchemy instance
|
||||||
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
# Import all models to maintain backward compatibility
|
||||||
|
from .enums import (
|
||||||
|
Role, AccountType, WorkRegion, CommentVisibility,
|
||||||
|
TaskStatus, TaskPriority, SprintStatus, WidgetType, WidgetSize
|
||||||
|
)
|
||||||
|
|
||||||
|
from .company import Company, CompanySettings, CompanyWorkConfig
|
||||||
|
from .user import User, UserPreferences, UserDashboard
|
||||||
|
from .team import Team
|
||||||
|
from .project import Project, ProjectCategory
|
||||||
|
from .task import Task, TaskDependency, SubTask, Comment
|
||||||
|
from .time_entry import TimeEntry
|
||||||
|
from .sprint import Sprint
|
||||||
|
from .system import SystemSettings, BrandingSettings, SystemEvent
|
||||||
|
from .announcement import Announcement
|
||||||
|
from .dashboard import DashboardWidget, WidgetTemplate
|
||||||
|
from .work_config import WorkConfig
|
||||||
|
from .invitation import CompanyInvitation
|
||||||
|
from .note import Note, NoteVisibility, NoteLink, NoteFolder
|
||||||
|
|
||||||
|
# Make all models available at package level
|
||||||
|
__all__ = [
|
||||||
|
'db',
|
||||||
|
# Enums
|
||||||
|
'Role', 'AccountType', 'WorkRegion', 'CommentVisibility',
|
||||||
|
'TaskStatus', 'TaskPriority', 'SprintStatus', 'WidgetType', 'WidgetSize',
|
||||||
|
# Models
|
||||||
|
'Company', 'CompanySettings', 'CompanyWorkConfig',
|
||||||
|
'User', 'UserPreferences', 'UserDashboard',
|
||||||
|
'Team',
|
||||||
|
'Project', 'ProjectCategory',
|
||||||
|
'Task', 'TaskDependency', 'SubTask', 'Comment',
|
||||||
|
'TimeEntry',
|
||||||
|
'Sprint',
|
||||||
|
'SystemSettings', 'BrandingSettings', 'SystemEvent',
|
||||||
|
'Announcement',
|
||||||
|
'DashboardWidget', 'WidgetTemplate',
|
||||||
|
'WorkConfig',
|
||||||
|
'CompanyInvitation',
|
||||||
|
'Note', 'NoteVisibility', 'NoteLink', 'NoteFolder'
|
||||||
|
]
|
||||||
91
models/announcement.py
Normal file
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}>'
|
||||||
278
models/note.py
Normal file
278
models/note.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
"""
|
||||||
|
Note models for markdown-based documentation and knowledge management.
|
||||||
|
Migrated from models_old.py to maintain consistency with the new modular structure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import UniqueConstraint
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .enums import Role
|
||||||
|
|
||||||
|
|
||||||
|
class NoteVisibility(enum.Enum):
|
||||||
|
"""Note sharing visibility levels"""
|
||||||
|
PRIVATE = "Private"
|
||||||
|
TEAM = "Team"
|
||||||
|
COMPANY = "Company"
|
||||||
|
|
||||||
|
|
||||||
|
class Note(db.Model):
|
||||||
|
"""Markdown notes with sharing capabilities"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(200), nullable=False)
|
||||||
|
content = db.Column(db.Text, nullable=False) # Markdown content
|
||||||
|
slug = db.Column(db.String(100), nullable=False) # URL-friendly identifier
|
||||||
|
|
||||||
|
# Visibility and sharing
|
||||||
|
visibility = db.Column(db.Enum(NoteVisibility), nullable=False, default=NoteVisibility.PRIVATE)
|
||||||
|
|
||||||
|
# Folder organization
|
||||||
|
folder = db.Column(db.String(100), nullable=True) # Folder path like "Work/Projects" or "Personal"
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
# Associations
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||||
|
|
||||||
|
# Optional associations
|
||||||
|
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True) # For team-specific notes
|
||||||
|
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True) # Link to project
|
||||||
|
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True) # Link to task
|
||||||
|
|
||||||
|
# Tags for organization
|
||||||
|
tags = db.Column(db.String(500)) # Comma-separated tags
|
||||||
|
|
||||||
|
# Pin important notes
|
||||||
|
is_pinned = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
|
# Soft delete
|
||||||
|
is_archived = db.Column(db.Boolean, default=False)
|
||||||
|
archived_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='notes')
|
||||||
|
company = db.relationship('Company', backref='notes')
|
||||||
|
team = db.relationship('Team', backref='notes')
|
||||||
|
project = db.relationship('Project', backref='notes')
|
||||||
|
task = db.relationship('Task', backref='notes')
|
||||||
|
|
||||||
|
# Unique constraint on slug per company
|
||||||
|
__table_args__ = (db.UniqueConstraint('company_id', 'slug', name='uq_note_slug_per_company'),)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Note {self.title}>'
|
||||||
|
|
||||||
|
def generate_slug(self):
|
||||||
|
"""Generate URL-friendly slug from title and set it on the model"""
|
||||||
|
import re
|
||||||
|
# Remove special characters and convert to lowercase
|
||||||
|
slug = re.sub(r'[^\w\s-]', '', self.title.lower())
|
||||||
|
# Replace spaces with hyphens
|
||||||
|
slug = re.sub(r'[-\s]+', '-', slug)
|
||||||
|
# Remove leading/trailing hyphens
|
||||||
|
slug = slug.strip('-')
|
||||||
|
|
||||||
|
# Ensure uniqueness within company
|
||||||
|
base_slug = slug
|
||||||
|
counter = 1
|
||||||
|
while Note.query.filter_by(company_id=self.company_id, slug=slug).filter(Note.id != self.id).first():
|
||||||
|
slug = f"{base_slug}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
self.slug = slug
|
||||||
|
return slug
|
||||||
|
|
||||||
|
def can_user_view(self, user):
|
||||||
|
"""Check if user can view this note"""
|
||||||
|
# Creator can always view
|
||||||
|
if user.id == self.created_by_id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check company match
|
||||||
|
if user.company_id != self.company_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check visibility
|
||||||
|
if self.visibility == NoteVisibility.COMPANY:
|
||||||
|
return True
|
||||||
|
elif self.visibility == NoteVisibility.TEAM:
|
||||||
|
# Check if user is in the same team
|
||||||
|
if self.team_id and user.team_id == self.team_id:
|
||||||
|
return True
|
||||||
|
# Admins can view all team notes
|
||||||
|
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def can_user_edit(self, user):
|
||||||
|
"""Check if user can edit this note"""
|
||||||
|
# Creator can always edit
|
||||||
|
if user.id == self.created_by_id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Admins can edit company notes
|
||||||
|
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN] and user.company_id == self.company_id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_tags_list(self):
|
||||||
|
"""Get tags as a list"""
|
||||||
|
if not self.tags:
|
||||||
|
return []
|
||||||
|
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
|
||||||
|
|
||||||
|
def set_tags_list(self, tags_list):
|
||||||
|
"""Set tags from a list"""
|
||||||
|
self.tags = ','.join(tags_list) if tags_list else None
|
||||||
|
|
||||||
|
def get_preview(self, length=200):
|
||||||
|
"""Get a plain text preview of the note content"""
|
||||||
|
# Strip markdown formatting for preview
|
||||||
|
import re
|
||||||
|
from frontmatter_utils import parse_frontmatter
|
||||||
|
|
||||||
|
# Extract body content without frontmatter
|
||||||
|
_, body = parse_frontmatter(self.content)
|
||||||
|
text = body
|
||||||
|
|
||||||
|
# Remove headers
|
||||||
|
text = re.sub(r'^#+\s+', '', text, flags=re.MULTILINE)
|
||||||
|
# Remove emphasis
|
||||||
|
text = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text)
|
||||||
|
text = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text)
|
||||||
|
# Remove links
|
||||||
|
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
|
||||||
|
# Remove code blocks
|
||||||
|
text = re.sub(r'```[^`]*```', '', text, flags=re.DOTALL)
|
||||||
|
text = re.sub(r'`([^`]+)`', r'\1', text)
|
||||||
|
# Clean up whitespace
|
||||||
|
text = ' '.join(text.split())
|
||||||
|
|
||||||
|
if len(text) > length:
|
||||||
|
return text[:length] + '...'
|
||||||
|
return text
|
||||||
|
|
||||||
|
def render_html(self):
|
||||||
|
"""Render markdown content to HTML"""
|
||||||
|
try:
|
||||||
|
import markdown
|
||||||
|
from frontmatter_utils import parse_frontmatter
|
||||||
|
# Extract body content without frontmatter
|
||||||
|
_, body = parse_frontmatter(self.content)
|
||||||
|
# Use extensions for better markdown support
|
||||||
|
html = markdown.markdown(body, extensions=['extra', 'codehilite', 'toc'])
|
||||||
|
return html
|
||||||
|
except ImportError:
|
||||||
|
# Fallback if markdown not installed
|
||||||
|
return f'<pre>{self.content}</pre>'
|
||||||
|
|
||||||
|
def get_frontmatter(self):
|
||||||
|
"""Get frontmatter metadata from content"""
|
||||||
|
from frontmatter_utils import parse_frontmatter
|
||||||
|
metadata, _ = parse_frontmatter(self.content)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
def update_frontmatter(self):
|
||||||
|
"""Update content with current metadata as frontmatter"""
|
||||||
|
from frontmatter_utils import update_frontmatter
|
||||||
|
metadata = {
|
||||||
|
'title': self.title,
|
||||||
|
'visibility': self.visibility.value.lower(),
|
||||||
|
'folder': self.folder,
|
||||||
|
'tags': self.get_tags_list() if self.tags else None,
|
||||||
|
'project': self.project.code if self.project else None,
|
||||||
|
'task_id': self.task_id,
|
||||||
|
'pinned': self.is_pinned if self.is_pinned else None,
|
||||||
|
'created': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'updated': self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
'author': self.created_by.username if self.created_by else None
|
||||||
|
}
|
||||||
|
# Remove None values
|
||||||
|
metadata = {k: v for k, v in metadata.items() if v is not None}
|
||||||
|
self.content = update_frontmatter(self.content, metadata)
|
||||||
|
|
||||||
|
def sync_from_frontmatter(self):
|
||||||
|
"""Update model fields from frontmatter in content"""
|
||||||
|
from frontmatter_utils import parse_frontmatter
|
||||||
|
metadata, _ = parse_frontmatter(self.content)
|
||||||
|
|
||||||
|
if metadata:
|
||||||
|
# Update fields from frontmatter
|
||||||
|
if 'title' in metadata:
|
||||||
|
self.title = metadata['title']
|
||||||
|
if 'visibility' in metadata:
|
||||||
|
try:
|
||||||
|
self.visibility = NoteVisibility[metadata['visibility'].upper()]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
if 'folder' in metadata:
|
||||||
|
self.folder = metadata['folder']
|
||||||
|
if 'tags' in metadata:
|
||||||
|
if isinstance(metadata['tags'], list):
|
||||||
|
self.set_tags_list(metadata['tags'])
|
||||||
|
elif isinstance(metadata['tags'], str):
|
||||||
|
self.tags = metadata['tags']
|
||||||
|
if 'pinned' in metadata:
|
||||||
|
self.is_pinned = bool(metadata['pinned'])
|
||||||
|
|
||||||
|
|
||||||
|
class NoteLink(db.Model):
|
||||||
|
"""Links between notes for creating relationships"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
||||||
|
# Source and target notes with cascade deletion
|
||||||
|
source_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
|
||||||
|
target_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
|
||||||
|
|
||||||
|
# Link metadata
|
||||||
|
link_type = db.Column(db.String(50), default='related') # related, parent, child, etc.
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
|
# Relationships with cascade deletion
|
||||||
|
source_note = db.relationship('Note', foreign_keys=[source_note_id],
|
||||||
|
backref=db.backref('outgoing_links', cascade='all, delete-orphan'))
|
||||||
|
target_note = db.relationship('Note', foreign_keys=[target_note_id],
|
||||||
|
backref=db.backref('incoming_links', cascade='all, delete-orphan'))
|
||||||
|
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||||
|
|
||||||
|
# Unique constraint to prevent duplicate links
|
||||||
|
__table_args__ = (db.UniqueConstraint('source_note_id', 'target_note_id', name='uq_note_link'),)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<NoteLink {self.source_note_id} -> {self.target_note_id}>'
|
||||||
|
|
||||||
|
|
||||||
|
class NoteFolder(db.Model):
|
||||||
|
"""Represents a folder for organizing notes"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
||||||
|
# Folder properties
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
path = db.Column(db.String(500), nullable=False) # Full path like "Work/Projects/Q1"
|
||||||
|
parent_path = db.Column(db.String(500), nullable=True) # Parent folder path
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||||
|
company = db.relationship('Company', foreign_keys=[company_id])
|
||||||
|
|
||||||
|
# Unique constraint to prevent duplicate paths within a company
|
||||||
|
__table_args__ = (db.UniqueConstraint('path', 'company_id', name='uq_folder_path_company'),)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<NoteFolder {self.path}>'
|
||||||
86
models/project.py
Normal file
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'))
|
||||||
112
routes/auth.py
112
routes/auth.py
@@ -1,14 +1,13 @@
|
|||||||
# Standard library imports
|
"""
|
||||||
|
Authentication decorators for route protection
|
||||||
|
"""
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from flask import g, redirect, url_for, flash, request
|
||||||
# Third-party imports
|
from models import Role, Company
|
||||||
from flask import flash, g, redirect, request, url_for
|
|
||||||
|
|
||||||
# Local application imports
|
|
||||||
from models import Company, Role, User
|
|
||||||
|
|
||||||
|
|
||||||
def login_required(f):
|
def login_required(f):
|
||||||
|
"""Decorator to require login for routes"""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
if g.user is None:
|
if g.user is None:
|
||||||
@@ -17,60 +16,85 @@ def login_required(f):
|
|||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
def company_required(f):
|
def role_required(min_role):
|
||||||
"""
|
"""Decorator to require a minimum role for routes"""
|
||||||
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 account is inactive.', 'error')
|
|
||||||
return redirect(url_for('home'))
|
|
||||||
|
|
||||||
return f(*args, **kwargs)
|
|
||||||
return decorated_function
|
|
||||||
|
|
||||||
|
|
||||||
def role_required(*allowed_roles):
|
|
||||||
def decorator(f):
|
def decorator(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
if g.user.role not in allowed_roles:
|
if g.user is None:
|
||||||
flash('You do not have permission to access this page.', 'error')
|
return redirect(url_for('login', next=request.url))
|
||||||
return redirect(url_for('dashboard'))
|
|
||||||
|
# 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 f(*args, **kwargs)
|
||||||
return decorated_function
|
return decorated_function
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def admin_required(f):
|
def company_required(f):
|
||||||
|
"""Decorator to ensure user has a valid company association and set company context"""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
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]:
|
if g.user.role not in [Role.ADMIN, Role.SYSTEM_ADMIN]:
|
||||||
flash('Admin access required.', 'error')
|
flash('You must be an administrator to access this page.', 'error')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('home'))
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
def system_admin_required(f):
|
def system_admin_required(f):
|
||||||
|
"""Decorator to require system admin role for routes"""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
|
if g.user is None:
|
||||||
|
return redirect(url_for('login'))
|
||||||
if g.user.role != Role.SYSTEM_ADMIN:
|
if g.user.role != Role.SYSTEM_ADMIN:
|
||||||
flash('System admin access required.', 'error')
|
flash('You must be a system administrator to access this page.', 'error')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('home'))
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
return decorated_function
|
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'))
|
||||||
@@ -195,6 +195,7 @@ def create_note():
|
|||||||
tags = request.form.get('tags', '').strip()
|
tags = request.form.get('tags', '').strip()
|
||||||
project_id = request.form.get('project_id')
|
project_id = request.form.get('project_id')
|
||||||
task_id = request.form.get('task_id')
|
task_id = request.form.get('task_id')
|
||||||
|
is_pinned = request.form.get('is_pinned') == '1'
|
||||||
|
|
||||||
# Validate
|
# Validate
|
||||||
if not title:
|
if not title:
|
||||||
@@ -240,9 +241,13 @@ def create_note():
|
|||||||
company_id=g.user.company_id,
|
company_id=g.user.company_id,
|
||||||
created_by_id=g.user.id,
|
created_by_id=g.user.id,
|
||||||
project_id=project.id if project else None,
|
project_id=project.id if project else None,
|
||||||
task_id=task.id if task else None
|
task_id=task.id if task else None,
|
||||||
|
is_pinned=is_pinned
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Generate slug before saving
|
||||||
|
note.generate_slug()
|
||||||
|
|
||||||
db.session.add(note)
|
db.session.add(note)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -258,7 +263,7 @@ def create_note():
|
|||||||
# Get projects for dropdown
|
# Get projects for dropdown
|
||||||
projects = Project.query.filter_by(
|
projects = Project.query.filter_by(
|
||||||
company_id=g.user.company_id,
|
company_id=g.user.company_id,
|
||||||
is_archived=False
|
is_active=True
|
||||||
).order_by(Project.name).all()
|
).order_by(Project.name).all()
|
||||||
|
|
||||||
# Get task if specified in URL
|
# Get task if specified in URL
|
||||||
@@ -359,6 +364,7 @@ def edit_note(slug):
|
|||||||
tags = request.form.get('tags', '').strip()
|
tags = request.form.get('tags', '').strip()
|
||||||
project_id = request.form.get('project_id')
|
project_id = request.form.get('project_id')
|
||||||
task_id = request.form.get('task_id')
|
task_id = request.form.get('task_id')
|
||||||
|
is_pinned = request.form.get('is_pinned') == '1'
|
||||||
|
|
||||||
# Validate
|
# Validate
|
||||||
if not title:
|
if not title:
|
||||||
@@ -402,6 +408,7 @@ def edit_note(slug):
|
|||||||
note.tags = tags if tags else None
|
note.tags = tags if tags else None
|
||||||
note.project_id = project.id if project else None
|
note.project_id = project.id if project else None
|
||||||
note.task_id = task.id if task else None
|
note.task_id = task.id if task else None
|
||||||
|
note.is_pinned = is_pinned
|
||||||
note.updated_at = datetime.now(timezone.utc)
|
note.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# Update slug if title changed
|
# Update slug if title changed
|
||||||
@@ -421,7 +428,7 @@ def edit_note(slug):
|
|||||||
# Get projects for dropdown
|
# Get projects for dropdown
|
||||||
projects = Project.query.filter_by(
|
projects = Project.query.filter_by(
|
||||||
company_id=g.user.company_id,
|
company_id=g.user.company_id,
|
||||||
is_archived=False
|
is_active=True
|
||||||
).order_by(Project.name).all()
|
).order_by(Project.name).all()
|
||||||
|
|
||||||
return render_template('note_editor.html',
|
return render_template('note_editor.html',
|
||||||
|
|||||||
@@ -245,6 +245,36 @@ def update_note_folder(slug):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@notes_api_bp.route('/<int:note_id>/move', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@company_required
|
||||||
|
def move_note_to_folder(note_id):
|
||||||
|
"""Move a note to a different folder (used by drag and drop)"""
|
||||||
|
note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first()
|
||||||
|
|
||||||
|
if not note:
|
||||||
|
return jsonify({'success': False, 'error': 'Note not found'}), 404
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if not note.can_user_edit(g.user):
|
||||||
|
return jsonify({'success': False, 'error': 'Permission denied'}), 403
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
new_folder = data.get('folder', '').strip()
|
||||||
|
|
||||||
|
# Update note folder
|
||||||
|
note.folder = new_folder if new_folder else None
|
||||||
|
note.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Note moved successfully',
|
||||||
|
'folder': note.folder or ''
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@notes_api_bp.route('/<int:note_id>/tags', methods=['POST'])
|
@notes_api_bp.route('/<int:note_id>/tags', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@company_required
|
@company_required
|
||||||
|
|||||||
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
|
done
|
||||||
echo "PostgreSQL is ready!"
|
echo "PostgreSQL is ready!"
|
||||||
|
|
||||||
# Check if SQLite database exists and has data
|
# SQLite to PostgreSQL migration is now handled by the migration system below
|
||||||
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
|
|
||||||
|
|
||||||
# Initialize database tables if they don't exist
|
# Initialize database tables if they don't exist
|
||||||
echo "Ensuring database tables exist..."
|
echo "Ensuring database tables exist..."
|
||||||
@@ -55,6 +22,35 @@ with app.app_context():
|
|||||||
print('Database tables created/verified')
|
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
|
# Start the Flask application with gunicorn
|
||||||
|
echo ""
|
||||||
|
echo "=== Starting Application ==="
|
||||||
echo "Starting Flask application with gunicorn..."
|
echo "Starting Flask application with gunicorn..."
|
||||||
exec gunicorn --bind 0.0.0.0:5000 --workers 4 --threads 2 --timeout 30 app:app
|
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() {
|
function addSubtask() {
|
||||||
const newSubtask = {
|
const newSubtask = {
|
||||||
name: '',
|
name: '',
|
||||||
status: 'NOT_STARTED',
|
status: 'TODO',
|
||||||
priority: 'MEDIUM',
|
priority: 'MEDIUM',
|
||||||
assigned_to_id: null,
|
assigned_to_id: null,
|
||||||
isNew: true
|
isNew: true
|
||||||
@@ -143,7 +143,7 @@ function updateSubtaskAssignee(index, assigneeId) {
|
|||||||
// Toggle subtask status
|
// Toggle subtask status
|
||||||
function toggleSubtaskStatus(index) {
|
function toggleSubtaskStatus(index) {
|
||||||
const subtask = currentSubtasks[index];
|
const subtask = currentSubtasks[index];
|
||||||
const newStatus = subtask.status === 'COMPLETED' ? 'NOT_STARTED' : 'COMPLETED';
|
const newStatus = subtask.status === 'DONE' ? 'TODO' : 'DONE';
|
||||||
|
|
||||||
if (subtask.id) {
|
if (subtask.id) {
|
||||||
// Update in database
|
// 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' %}
|
{% extends 'layout.html' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="admin-container">
|
<div class="teams-admin-container">
|
||||||
<div class="admin-header">
|
<!-- Header Section -->
|
||||||
<h1>Team Management</h1>
|
<div class="page-header">
|
||||||
<a href="{{ url_for('create_team') }}" class="btn btn-md btn-success">Create New Team</a>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<!-- Team Statistics -->
|
||||||
{% if teams %}
|
{% if teams %}
|
||||||
<table class="data-table">
|
<div class="stats-section">
|
||||||
<thead>
|
<div class="stat-card">
|
||||||
<tr>
|
<div class="stat-value">{{ teams|length if teams else 0 }}</div>
|
||||||
<th>Name</th>
|
<div class="stat-label">Total Teams</div>
|
||||||
<th>Description</th>
|
</div>
|
||||||
<th>Members</th>
|
<div class="stat-card">
|
||||||
<th>Created</th>
|
<div class="stat-value">{{ teams|map(attribute='users')|map('length')|sum if teams else 0 }}</div>
|
||||||
<th>Actions</th>
|
<div class="stat-label">Total Members</div>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<div class="stat-card">
|
||||||
<tbody>
|
<div class="stat-value">{{ (teams|map(attribute='users')|map('length')|sum / teams|length)|round(1) if teams else 0 }}</div>
|
||||||
{% for team in teams %}
|
<div class="stat-label">Avg Team Size</div>
|
||||||
<tr>
|
</div>
|
||||||
<td>{{ team.name }}</td>
|
</div>
|
||||||
<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>
|
|
||||||
{% endif %}
|
{% 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>
|
</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 %}
|
{% endblock %}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -45,9 +45,9 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="work_hours_per_day">Standard Work Hours Per Day:</label>
|
<label for="work_hours_per_day">Standard Work Hours Per Day:</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="work_hours_per_day"
|
id="standard_hours_per_day"
|
||||||
name="work_hours_per_day"
|
name="standard_hours_per_day"
|
||||||
value="{{ work_config.work_hours_per_day }}"
|
value="{{ work_config.standard_hours_per_day }}"
|
||||||
min="1"
|
min="1"
|
||||||
max="24"
|
max="24"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
@@ -61,9 +61,9 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="mandatory_break_minutes">Mandatory Break Duration (minutes):</label>
|
<label for="mandatory_break_minutes">Mandatory Break Duration (minutes):</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="mandatory_break_minutes"
|
id="break_duration_minutes"
|
||||||
name="mandatory_break_minutes"
|
name="break_duration_minutes"
|
||||||
value="{{ work_config.mandatory_break_minutes }}"
|
value="{{ work_config.break_duration_minutes }}"
|
||||||
min="0"
|
min="0"
|
||||||
max="240"
|
max="240"
|
||||||
required>
|
required>
|
||||||
@@ -73,9 +73,9 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="break_threshold_hours">Break Threshold (hours):</label>
|
<label for="break_threshold_hours">Break Threshold (hours):</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
id="break_threshold_hours"
|
id="break_after_hours"
|
||||||
name="break_threshold_hours"
|
name="break_after_hours"
|
||||||
value="{{ work_config.break_threshold_hours }}"
|
value="{{ work_config.break_after_hours }}"
|
||||||
min="0"
|
min="0"
|
||||||
max="24"
|
max="24"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
@@ -116,11 +116,11 @@
|
|||||||
<div class="current-config">
|
<div class="current-config">
|
||||||
<h4>Current Configuration Summary</h4>
|
<h4>Current Configuration Summary</h4>
|
||||||
<div class="config-summary">
|
<div class="config-summary">
|
||||||
<strong>Region:</strong> {{ work_config.region_name }}<br>
|
<strong>Region:</strong> {{ work_config.work_region.value }}<br>
|
||||||
<strong>Work Day:</strong> {{ work_config.work_hours_per_day }} hours<br>
|
<strong>Work Day:</strong> {{ work_config.standard_hours_per_day }} hours<br>
|
||||||
<strong>Break Policy:</strong>
|
<strong>Break Policy:</strong>
|
||||||
{% if work_config.mandatory_break_minutes > 0 %}
|
{% 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 %}
|
{% else %}
|
||||||
No mandatory breaks
|
No mandatory breaks
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Save Custom Configuration</button>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -120,16 +120,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="chart-stats">
|
<div class="chart-stats">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h4>Total Hours</h4>
|
|
||||||
<span id="total-hours">0</span>
|
<span id="total-hours">0</span>
|
||||||
|
<h4 id="stat-label-1">Total Hours</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h4>Total Days</h4>
|
|
||||||
<span id="total-days">0</span>
|
<span id="total-days">0</span>
|
||||||
|
<h4 id="stat-label-2">Total Days</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h4>Average Hours/Day</h4>
|
|
||||||
<span id="avg-hours">0</span>
|
<span id="avg-hours">0</span>
|
||||||
|
<h4 id="stat-label-3">Average Hours/Day</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -417,9 +417,9 @@ class TimeAnalyticsController {
|
|||||||
document.getElementById('avg-hours').textContent = data.burndown.tasks_completed || '0';
|
document.getElementById('avg-hours').textContent = data.burndown.tasks_completed || '0';
|
||||||
|
|
||||||
// Update stat labels for burndown
|
// Update stat labels for burndown
|
||||||
document.querySelector('.stat-card:nth-child(1) h4').textContent = 'Total Tasks';
|
document.getElementById('stat-label-1').textContent = 'Total Tasks';
|
||||||
document.querySelector('.stat-card:nth-child(2) h4').textContent = 'Timeline Days';
|
document.getElementById('stat-label-2').textContent = 'Timeline Days';
|
||||||
document.querySelector('.stat-card:nth-child(3) h4').textContent = 'Completed Tasks';
|
document.getElementById('stat-label-3').textContent = 'Completed Tasks';
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('total-hours').textContent = data.totalHours?.toFixed(1) || '0';
|
document.getElementById('total-hours').textContent = data.totalHours?.toFixed(1) || '0';
|
||||||
document.getElementById('total-days').textContent = data.totalDays || '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';
|
data.totalDays > 0 ? (data.totalHours / data.totalDays).toFixed(1) : '0';
|
||||||
|
|
||||||
// Restore original stat labels
|
// Restore original stat labels
|
||||||
document.querySelector('.stat-card:nth-child(1) h4').textContent = 'Total Hours';
|
document.getElementById('stat-label-1').textContent = 'Total Hours';
|
||||||
document.querySelector('.stat-card:nth-child(2) h4').textContent = 'Total Days';
|
document.getElementById('stat-label-2').textContent = 'Total Days';
|
||||||
document.querySelector('.stat-card:nth-child(3) h4').textContent = 'Average Hours/Day';
|
document.getElementById('stat-label-3').textContent = 'Average Hours/Day';
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateChart();
|
this.updateChart();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="admin-container">
|
<div class="admin-container">
|
||||||
<div class="admin-header">
|
<div class="admin-header">
|
||||||
<h1>Company Users - {{ company.name }}</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- User Statistics -->
|
<!-- User Statistics -->
|
||||||
@@ -84,13 +84,15 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
|
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
|
||||||
<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.id != g.user.id %}
|
||||||
{% if user.is_blocked %}
|
<form method="POST" action="{{ url_for('users.toggle_user_status', user_id=user.id) }}" style="display: inline;">
|
||||||
<a href="{{ url_for('toggle_user_status', user_id=user.id) }}" class="btn btn-sm btn-success">Unblock</a>
|
{% if user.is_blocked %}
|
||||||
{% else %}
|
<button type="submit" class="btn btn-sm btn-success">Unblock</button>
|
||||||
<a href="{{ url_for('toggle_user_status', user_id=user.id) }}" class="btn btn-sm btn-warning">Block</a>
|
{% else %}
|
||||||
{% endif %}
|
<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>
|
<button class="btn btn-sm btn-danger" onclick="confirmDelete({{ user.id }}, '{{ user.username }}')">Delete</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
@@ -103,14 +105,14 @@
|
|||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<h3>No Users Found</h3>
|
<h3>No Users Found</h3>
|
||||||
<p>There are no users in this company yet.</p>
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<div class="admin-section">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,15 +18,15 @@
|
|||||||
|
|
||||||
<div class="policy-info">
|
<div class="policy-info">
|
||||||
<div class="policy-item">
|
<div class="policy-item">
|
||||||
<strong>Region:</strong> {{ company_config.region_name }}
|
<strong>Region:</strong> {{ company_config.work_region.value }}
|
||||||
</div>
|
</div>
|
||||||
<div class="policy-item">
|
<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>
|
||||||
<div class="policy-item">
|
<div class="policy-item">
|
||||||
<strong>Break Policy:</strong>
|
<strong>Break Policy:</strong>
|
||||||
{% if company_config.mandatory_break_minutes > 0 %}
|
{% 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 %}
|
{% else %}
|
||||||
No mandatory breaks
|
No mandatory breaks
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
<h1>⚠️ Confirm Company Deletion</h1>
|
<h1>⚠️ Confirm Company Deletion</h1>
|
||||||
<p class="subtitle">Critical Action Required - Review All Data Before Proceeding</p>
|
<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>
|
class="btn btn-md btn-secondary">← Back to User Management</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<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>
|
class="btn btn-secondary">Cancel</a>
|
||||||
<button type="submit" class="btn btn-danger">
|
<button type="submit" class="btn btn-danger">
|
||||||
Delete Company and All Data
|
Delete Company and All Data
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="timetrack-container">
|
<div class="timetrack-container">
|
||||||
<h2>Create New Project</h2>
|
<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-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name">Project Name *</label>
|
<label for="name">Project Name *</label>
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn">Create Project</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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">
|
<div class="admin-container">
|
||||||
<h1>Create New User</h1>
|
<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">
|
<div class="form-group">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input type="text" id="username" name="username" class="form-control" required autofocus>
|
<input type="text" id="username" name="username" class="form-control" required autofocus>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<button type="submit" class="btn btn-success">Create User</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -918,6 +918,7 @@ function loadDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderDashboard() {
|
function renderDashboard() {
|
||||||
|
console.log('Rendering dashboard with widgets:', widgets);
|
||||||
const grid = document.getElementById('dashboard-grid');
|
const grid = document.getElementById('dashboard-grid');
|
||||||
const emptyMessage = document.getElementById('empty-dashboard');
|
const emptyMessage = document.getElementById('empty-dashboard');
|
||||||
|
|
||||||
@@ -930,6 +931,10 @@ function renderDashboard() {
|
|||||||
grid.style.display = 'grid';
|
grid.style.display = 'grid';
|
||||||
emptyMessage.style.display = 'none';
|
emptyMessage.style.display = 'none';
|
||||||
|
|
||||||
|
// Clear timer intervals before clearing widgets
|
||||||
|
Object.values(timerIntervals).forEach(interval => clearInterval(interval));
|
||||||
|
timerIntervals = {};
|
||||||
|
|
||||||
// Clear existing widgets
|
// Clear existing widgets
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
|
|
||||||
@@ -939,8 +944,11 @@ function renderDashboard() {
|
|||||||
return a.grid_x - b.grid_x;
|
return a.grid_x - b.grid_x;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('Sorted widgets:', widgets);
|
||||||
|
|
||||||
// Render each widget
|
// Render each widget
|
||||||
widgets.forEach(widget => {
|
widgets.forEach(widget => {
|
||||||
|
console.log('Creating widget element for:', widget);
|
||||||
const widgetElement = createWidgetElement(widget);
|
const widgetElement = createWidgetElement(widget);
|
||||||
grid.appendChild(widgetElement);
|
grid.appendChild(widgetElement);
|
||||||
});
|
});
|
||||||
@@ -949,6 +957,9 @@ function renderDashboard() {
|
|||||||
if (isCustomizing) {
|
if (isCustomizing) {
|
||||||
initializeDragAndDrop();
|
initializeDragAndDrop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset global timer state to force refresh
|
||||||
|
globalTimerState = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWidgetElement(widget) {
|
function createWidgetElement(widget) {
|
||||||
@@ -1397,6 +1408,7 @@ function configureWidget(widgetId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeWidget(widgetId) {
|
function removeWidget(widgetId) {
|
||||||
|
console.log('Removing widget with ID:', widgetId);
|
||||||
if (!confirm('Are you sure you want to remove this widget?')) return;
|
if (!confirm('Are you sure you want to remove this widget?')) return;
|
||||||
|
|
||||||
fetch(`/api/dashboard/widgets/${widgetId}`, {
|
fetch(`/api/dashboard/widgets/${widgetId}`, {
|
||||||
@@ -1404,6 +1416,7 @@ function removeWidget(widgetId) {
|
|||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
console.log('Remove widget response:', data);
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
} else {
|
} 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">
|
<div class="timetrack-container">
|
||||||
<h2>Edit Project: {{ project.name }}</h2>
|
<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-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name">Project Name *</label>
|
<label for="name">Project Name *</label>
|
||||||
@@ -96,9 +96,29 @@
|
|||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn">Update Project</button>
|
<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>
|
</div>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -194,6 +214,47 @@
|
|||||||
#code {
|
#code {
|
||||||
text-transform: uppercase;
|
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>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="admin-container">
|
<div class="admin-container">
|
||||||
<h1>Edit User: {{ user.username }}</h1>
|
<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">
|
<div class="form-group">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input type="text" id="username" name="username" class="form-control" value="{{ user.username }}" required>
|
<input type="text" id="username" name="username" class="form-control" value="{{ user.username }}" required>
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<button type="submit" class="btn btn-primary">Update User</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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-options">
|
||||||
<div class="export-section">
|
<div class="export-section">
|
||||||
<h3>Date Range</h3>
|
<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">
|
<div class="form-group">
|
||||||
<label for="start_date">Start Date:</label>
|
<label for="start_date">Start Date:</label>
|
||||||
<input type="date" id="start_date" name="start_date" required>
|
<input type="date" id="start_date" name="start_date" required>
|
||||||
@@ -33,14 +33,14 @@
|
|||||||
<div class="export-section">
|
<div class="export-section">
|
||||||
<h3>Quick Export</h3>
|
<h3>Quick Export</h3>
|
||||||
<div class="quick-export-buttons">
|
<div class="quick-export-buttons">
|
||||||
<a href="{{ url_for('download_export', period='today', format='csv') }}" class="btn">Today (CSV)</a>
|
<a href="{{ url_for('export.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('export.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('export.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('export.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('export.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('export.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('export.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='all', format='excel') }}" class="btn">All Time (Excel)</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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,32 +129,31 @@
|
|||||||
<ul>
|
<ul>
|
||||||
{% if g.user %}
|
{% 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('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('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('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('sprint_management') }}" data-tooltip="Sprint Management"><i class="nav-icon">🏃♂️</i><span class="nav-text">Sprints</span></a></li>
|
<li><a href="{{ url_for('sprints.sprint_management') }}" data-tooltip="Sprint Management"><i class="nav-icon">🏃♂️</i><span class="nav-text">Sprints</span></a></li>
|
||||||
<li><a href="{{ url_for('notes.notes_list') }}" data-tooltip="Notes"><i class="nav-icon">📝</i><span class="nav-text">Notes</span></a></li>
|
<li><a href="{{ url_for('notes.notes_list') }}" data-tooltip="Notes"><i class="nav-icon">📝</i><span class="nav-text">Notes</span></a></li>
|
||||||
<li><a href="{{ url_for('analytics') }}" data-tooltip="Time Analytics"><i class="nav-icon">📊</i><span class="nav-text">Analytics</span></a></li>
|
<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 -->
|
<!-- Role-based menu items -->
|
||||||
{% if g.user.role == Role.ADMIN or g.user.role == Role.SYSTEM_ADMIN %}
|
{% if g.user.role == Role.ADMIN or g.user.role == Role.SYSTEM_ADMIN %}
|
||||||
<li class="nav-divider">Admin</li>
|
<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('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('admin_company') }}" data-tooltip="Company"><i class="nav-icon">🏢</i><span class="nav-text">Company</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('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('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('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('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>
|
||||||
<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>
|
|
||||||
{% if g.user.role == Role.SYSTEM_ADMIN %}
|
{% if g.user.role == Role.SYSTEM_ADMIN %}
|
||||||
<li class="nav-divider">System Admin</li>
|
<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.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('announcements.index') }}" data-tooltip="Announcements"><i class="nav-icon">📢</i><span class="nav-text">Announcements</span></a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %}
|
{% elif g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %}
|
||||||
<li class="nav-divider">Team</li>
|
<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>
|
<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 %}
|
{% 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 %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="page-actions task-actions">
|
<div class="page-actions task-actions">
|
||||||
<button id="create-task-btn" class="btn btn-success">Create New Task</button>
|
<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>
|
||||||
</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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,12 +7,114 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
|
<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/style.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.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>
|
</head>
|
||||||
<body class="auth-page">
|
<body class="auth-page">
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<div class="auth-brand">
|
<div class="auth-brand">
|
||||||
<h1>Welcome to {{ g.branding.app_name if g.branding else 'TimeTrack' }}</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
@@ -23,22 +125,51 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<div class="registration-options mb-4">
|
<form method="POST" action="{{ url_for('register') }}" class="auth-form" id="registrationForm">
|
||||||
<div class="alert alert-info">
|
<!-- Registration Type Selection -->
|
||||||
<h5>Registration Options:</h5>
|
<div class="registration-type">
|
||||||
<p><strong>Company Employee:</strong> You're on the right page! Enter your company code below.</p>
|
<div class="type-card active" data-type="company" onclick="selectRegistrationType('company')">
|
||||||
<p><strong>Freelancer/Independent:</strong> <a href="{{ url_for('register_freelancer') }}" class="btn btn-outline-primary btn-sm">Register as Freelancer</a></p>
|
<span class="icon">🏢</span>
|
||||||
</div>
|
<h3>Company Employee</h3>
|
||||||
</div>
|
<p>Join an existing company</p>
|
||||||
|
</div>
|
||||||
<form method="POST" action="{{ url_for('register') }}" class="auth-form">
|
<div class="type-card" data-type="freelancer" onclick="selectRegistrationType('freelancer')">
|
||||||
<div class="form-group company-code-group">
|
<span class="icon">💼</span>
|
||||||
<label for="company_code">Company Code</label>
|
<h3>Freelancer</h3>
|
||||||
<input type="text" id="company_code" name="company_code" class="form-control" required autofocus
|
<p>Create personal workspace</p>
|
||||||
placeholder="ENTER-CODE">
|
</div>
|
||||||
<small class="form-text text-muted">Get this code from your company administrator</small>
|
|
||||||
</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">
|
<div class="form-group input-icon">
|
||||||
<i>👤</i>
|
<i>👤</i>
|
||||||
<input type="text" id="username" name="username" class="form-control" placeholder="Choose a username" required>
|
<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>
|
<p>Already have an account? <a href="{{ url_for('login') }}">Login here</a></p>
|
||||||
</div>
|
</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">
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/auth-animations.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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user