Squashed commit of the following:
commit 1eeea9f83ad9230a5c1f7a75662770eaab0df837 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 21:15:41 2025 +0200 Disable resuming of old time entries. commit 3e3ec2f01cb7943622b819a19179388078ae1315 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 20:59:19 2025 +0200 Refactor db migrations. commit 15a51a569da36c6b7c9e01ab17b6fdbdee6ad994 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 19:58:04 2025 +0200 Apply new style for Time Tracking view. commit 77e5278b303e060d2b03853b06277f8aa567ae68 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 18:06:04 2025 +0200 Allow direct registrations as a Company. commit 188a8772757cbef374243d3a5f29e4440ddecabe Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 18:04:45 2025 +0200 Add email invitation feature. commit d9ebaa02aa01b518960a20dccdd5a327d82f30c6 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 17:12:32 2025 +0200 Apply common style for Company, User, Team management pages. commit 81149caf4d8fc6317e2ab1b4f022b32fc5aa6d22 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 16:44:32 2025 +0200 Move export functions to own module. commit 1a26e19338e73f8849c671471dd15cc3c1b1fe82 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 15:51:15 2025 +0200 Split up models.py. commit 61f1ccd10f721b0ff4dc1eccf30c7a1ee13f204d Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 12:05:28 2025 +0200 Move utility function into own modules. commit 84b341ed35e2c5387819a8b9f9d41eca900ae79f Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 11:44:24 2025 +0200 Refactor auth functions use. commit 923e311e3da5b26d85845c2832b73b7b17c48adb Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 11:35:52 2025 +0200 Refactor route nameing and fix bugs along the way. commit f0a5c4419c340e62a2615c60b2a9de28204d2995 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 10:34:33 2025 +0200 Fix URL endpoints in announcement template. commit b74d74542a1c8dc350749e4788a9464d067a88b5 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 09:25:53 2025 +0200 Move announcements to own module. commit 9563a28021ac46c82c04fe4649b394dbf96f92c7 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 09:16:30 2025 +0200 Combine Company view and edit templates. commit 6687c373e681d54e4deab6b2582fed5cea9aadf6 Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 08:17:42 2025 +0200 Move Users, Company and System Administration to own modules. commit 8b7894a2e3eb84bb059f546648b6b9536fea724e Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 07:40:57 2025 +0200 Move Teams and Projects to own modules. commit d11bf059d99839ecf1f5d7020b8c8c8a2454c00b Author: Jens Luedicke <jens@luedicke.me> Date: Mon Jul 7 07:09:33 2025 +0200 Move Tasks and Sprints to own modules.
This commit is contained in:
@@ -44,11 +44,11 @@ RUN mkdir -p /app/static/uploads/avatars && chmod -R 777 /app/static/uploads
|
|||||||
VOLUME /data
|
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())
|
||||||
49
models/__init__.py
Normal file
49
models/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""
|
||||||
|
Models package for TimeTrack application.
|
||||||
|
Split from monolithic models.py into domain-specific modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
# Initialize SQLAlchemy instance
|
||||||
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
# Import all models to maintain backward compatibility
|
||||||
|
from .enums import (
|
||||||
|
Role, AccountType, WorkRegion, CommentVisibility,
|
||||||
|
TaskStatus, TaskPriority, SprintStatus, WidgetType, WidgetSize
|
||||||
|
)
|
||||||
|
|
||||||
|
from .company import Company, CompanySettings, CompanyWorkConfig
|
||||||
|
from .user import User, UserPreferences, UserDashboard
|
||||||
|
from .team import Team
|
||||||
|
from .project import Project, ProjectCategory
|
||||||
|
from .task import Task, TaskDependency, SubTask, Comment
|
||||||
|
from .time_entry import TimeEntry
|
||||||
|
from .sprint import Sprint
|
||||||
|
from .system import SystemSettings, BrandingSettings, SystemEvent
|
||||||
|
from .announcement import Announcement
|
||||||
|
from .dashboard import DashboardWidget, WidgetTemplate
|
||||||
|
from .work_config import WorkConfig
|
||||||
|
from .invitation import CompanyInvitation
|
||||||
|
|
||||||
|
# Make all models available at package level
|
||||||
|
__all__ = [
|
||||||
|
'db',
|
||||||
|
# Enums
|
||||||
|
'Role', 'AccountType', 'WorkRegion', 'CommentVisibility',
|
||||||
|
'TaskStatus', 'TaskPriority', 'SprintStatus', 'WidgetType', 'WidgetSize',
|
||||||
|
# Models
|
||||||
|
'Company', 'CompanySettings', 'CompanyWorkConfig',
|
||||||
|
'User', 'UserPreferences', 'UserDashboard',
|
||||||
|
'Team',
|
||||||
|
'Project', 'ProjectCategory',
|
||||||
|
'Task', 'TaskDependency', 'SubTask', 'Comment',
|
||||||
|
'TimeEntry',
|
||||||
|
'Sprint',
|
||||||
|
'SystemSettings', 'BrandingSettings', 'SystemEvent',
|
||||||
|
'Announcement',
|
||||||
|
'DashboardWidget', 'WidgetTemplate',
|
||||||
|
'WorkConfig',
|
||||||
|
'CompanyInvitation'
|
||||||
|
]
|
||||||
91
models/announcement.py
Normal file
91
models/announcement.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""
|
||||||
|
Announcement model for system-wide notifications
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
from . import db
|
||||||
|
|
||||||
|
|
||||||
|
class Announcement(db.Model):
|
||||||
|
"""System-wide announcements"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(200), nullable=False)
|
||||||
|
content = db.Column(db.Text, nullable=False)
|
||||||
|
|
||||||
|
# Announcement properties
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
is_urgent = db.Column(db.Boolean, default=False) # For urgent announcements with different styling
|
||||||
|
announcement_type = db.Column(db.String(20), default='info') # info, warning, success, danger
|
||||||
|
|
||||||
|
# Scheduling
|
||||||
|
start_date = db.Column(db.DateTime, nullable=True) # When to start showing
|
||||||
|
end_date = db.Column(db.DateTime, nullable=True) # When to stop showing
|
||||||
|
|
||||||
|
# Targeting
|
||||||
|
target_all_users = db.Column(db.Boolean, default=True)
|
||||||
|
target_roles = db.Column(db.Text, nullable=True) # JSON string of roles if not all users
|
||||||
|
target_companies = db.Column(db.Text, nullable=True) # JSON string of company IDs if not all companies
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Announcement {self.title}>'
|
||||||
|
|
||||||
|
def is_visible_now(self):
|
||||||
|
"""Check if announcement should be visible at current time"""
|
||||||
|
if not self.is_active:
|
||||||
|
return False
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
# Check start date
|
||||||
|
if self.start_date and now < self.start_date:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check end date
|
||||||
|
if self.end_date and now > self.end_date:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_visible_to_user(self, user):
|
||||||
|
"""Check if announcement should be visible to specific user"""
|
||||||
|
if not self.is_visible_now():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If targeting all users, show to everyone
|
||||||
|
if self.target_all_users:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check role targeting
|
||||||
|
if self.target_roles:
|
||||||
|
try:
|
||||||
|
target_roles = json.loads(self.target_roles)
|
||||||
|
if user.role.value not in target_roles:
|
||||||
|
return False
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check company targeting
|
||||||
|
if self.target_companies:
|
||||||
|
try:
|
||||||
|
target_companies = json.loads(self.target_companies)
|
||||||
|
if user.company_id not in target_companies:
|
||||||
|
return False
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_active_announcements_for_user(user):
|
||||||
|
"""Get all active announcements visible to a specific user"""
|
||||||
|
announcements = Announcement.query.filter_by(is_active=True).all()
|
||||||
|
return [ann for ann in announcements if ann.is_visible_to_user(user)]
|
||||||
20
models/base.py
Normal file
20
models/base.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
Base model utilities and mixins
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.ext.declarative import declared_attr
|
||||||
|
from . import db
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
"""Mixin for adding created_at and updated_at timestamps"""
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now, nullable=False)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyScopedMixin:
|
||||||
|
"""Mixin for models that belong to a company"""
|
||||||
|
@declared_attr
|
||||||
|
def company_id(cls):
|
||||||
|
return db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||||
232
models/company.py
Normal file
232
models/company.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
"""
|
||||||
|
Company-related models
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from . import db
|
||||||
|
from .enums import WorkRegion
|
||||||
|
|
||||||
|
|
||||||
|
class Company(db.Model):
|
||||||
|
"""Company model for multi-tenancy"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False, unique=True)
|
||||||
|
slug = db.Column(db.String(50), unique=True, nullable=False) # URL-friendly identifier
|
||||||
|
description = db.Column(db.Text)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
# Freelancer support
|
||||||
|
is_personal = db.Column(db.Boolean, default=False) # True for auto-created freelancer companies
|
||||||
|
|
||||||
|
# Company settings
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
max_users = db.Column(db.Integer, default=100) # Optional user limit
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
users = db.relationship('User', backref='company', lazy=True)
|
||||||
|
teams = db.relationship('Team', backref='company', lazy=True)
|
||||||
|
projects = db.relationship('Project', backref='company', lazy=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Company {self.name}>'
|
||||||
|
|
||||||
|
def generate_slug(self):
|
||||||
|
"""Generate URL-friendly slug from company name"""
|
||||||
|
import re
|
||||||
|
slug = re.sub(r'[^\w\s-]', '', self.name.lower())
|
||||||
|
slug = re.sub(r'[-\s]+', '-', slug)
|
||||||
|
return slug.strip('-')
|
||||||
|
|
||||||
|
|
||||||
|
class CompanySettings(db.Model):
|
||||||
|
"""Company-specific settings"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), unique=True, nullable=False)
|
||||||
|
|
||||||
|
# Work week settings
|
||||||
|
work_week_start = db.Column(db.Integer, default=1) # 1 = Monday, 7 = Sunday
|
||||||
|
work_days = db.Column(db.String(20), default='1,2,3,4,5') # Comma-separated day numbers
|
||||||
|
|
||||||
|
# Time tracking settings
|
||||||
|
allow_overlapping_entries = db.Column(db.Boolean, default=False)
|
||||||
|
require_project_for_time_entry = db.Column(db.Boolean, default=True)
|
||||||
|
allow_future_entries = db.Column(db.Boolean, default=False)
|
||||||
|
max_hours_per_entry = db.Column(db.Float, default=24.0)
|
||||||
|
|
||||||
|
# Feature toggles
|
||||||
|
enable_tasks = db.Column(db.Boolean, default=True)
|
||||||
|
enable_sprints = db.Column(db.Boolean, default=False)
|
||||||
|
enable_client_access = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
|
# Notification settings
|
||||||
|
notify_on_overtime = db.Column(db.Boolean, default=True)
|
||||||
|
overtime_threshold_daily = db.Column(db.Float, default=8.0)
|
||||||
|
overtime_threshold_weekly = db.Column(db.Float, default=40.0)
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
# Relationship
|
||||||
|
company = db.relationship('Company', backref=db.backref('settings', uselist=False))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_or_create(cls, company_id):
|
||||||
|
"""Get existing settings or create default ones"""
|
||||||
|
settings = cls.query.filter_by(company_id=company_id).first()
|
||||||
|
if not settings:
|
||||||
|
settings = cls(company_id=company_id)
|
||||||
|
db.session.add(settings)
|
||||||
|
db.session.commit()
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyWorkConfig(db.Model):
|
||||||
|
"""Company-specific work configuration"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||||
|
|
||||||
|
# Work hours configuration
|
||||||
|
standard_hours_per_day = db.Column(db.Float, default=8.0)
|
||||||
|
standard_hours_per_week = db.Column(db.Float, default=40.0)
|
||||||
|
|
||||||
|
# Work region for compliance
|
||||||
|
work_region = db.Column(db.Enum(WorkRegion), default=WorkRegion.OTHER)
|
||||||
|
|
||||||
|
# Overtime rules
|
||||||
|
overtime_enabled = db.Column(db.Boolean, default=True)
|
||||||
|
overtime_rate = db.Column(db.Float, default=1.5) # 1.5x regular rate
|
||||||
|
double_time_enabled = db.Column(db.Boolean, default=False)
|
||||||
|
double_time_threshold = db.Column(db.Float, default=12.0) # Hours after which double time applies
|
||||||
|
double_time_rate = db.Column(db.Float, default=2.0)
|
||||||
|
|
||||||
|
# Break rules
|
||||||
|
require_breaks = db.Column(db.Boolean, default=True)
|
||||||
|
break_duration_minutes = db.Column(db.Integer, default=30)
|
||||||
|
break_after_hours = db.Column(db.Float, default=6.0)
|
||||||
|
|
||||||
|
# Weekly overtime rules
|
||||||
|
weekly_overtime_threshold = db.Column(db.Float, default=40.0)
|
||||||
|
weekly_overtime_rate = db.Column(db.Float, default=1.5)
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
company = db.relationship('Company', backref='work_configs')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<CompanyWorkConfig {self.company.name if self.company else "Unknown"}: {self.work_region.value if self.work_region else "No region"}>'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_regional_preset(cls, region):
|
||||||
|
"""Get regional preset configuration."""
|
||||||
|
presets = {
|
||||||
|
WorkRegion.EU: {
|
||||||
|
'standard_hours_per_day': 8.0,
|
||||||
|
'standard_hours_per_week': 40.0,
|
||||||
|
'overtime_enabled': True,
|
||||||
|
'overtime_rate': 1.5,
|
||||||
|
'double_time_enabled': False,
|
||||||
|
'double_time_threshold': 12.0,
|
||||||
|
'double_time_rate': 2.0,
|
||||||
|
'require_breaks': True,
|
||||||
|
'break_duration_minutes': 30,
|
||||||
|
'break_after_hours': 6.0,
|
||||||
|
'weekly_overtime_threshold': 40.0,
|
||||||
|
'weekly_overtime_rate': 1.5,
|
||||||
|
'region_name': 'European Union'
|
||||||
|
},
|
||||||
|
WorkRegion.GERMANY: {
|
||||||
|
'standard_hours_per_day': 8.0,
|
||||||
|
'standard_hours_per_week': 40.0,
|
||||||
|
'overtime_enabled': True,
|
||||||
|
'overtime_rate': 1.25, # German overtime is typically 25% extra
|
||||||
|
'double_time_enabled': False,
|
||||||
|
'double_time_threshold': 10.0,
|
||||||
|
'double_time_rate': 1.5,
|
||||||
|
'require_breaks': True,
|
||||||
|
'break_duration_minutes': 30, # 30 min break after 6 hours
|
||||||
|
'break_after_hours': 6.0,
|
||||||
|
'weekly_overtime_threshold': 48.0, # German law allows up to 48 hours/week
|
||||||
|
'weekly_overtime_rate': 1.25,
|
||||||
|
'region_name': 'Germany'
|
||||||
|
},
|
||||||
|
WorkRegion.USA: {
|
||||||
|
'standard_hours_per_day': 8.0,
|
||||||
|
'standard_hours_per_week': 40.0,
|
||||||
|
'overtime_enabled': True,
|
||||||
|
'overtime_rate': 1.5,
|
||||||
|
'double_time_enabled': False,
|
||||||
|
'double_time_threshold': 12.0,
|
||||||
|
'double_time_rate': 2.0,
|
||||||
|
'require_breaks': False, # No federal requirement
|
||||||
|
'break_duration_minutes': 0,
|
||||||
|
'break_after_hours': 999.0, # Effectively disabled
|
||||||
|
'weekly_overtime_threshold': 40.0,
|
||||||
|
'weekly_overtime_rate': 1.5,
|
||||||
|
'region_name': 'United States'
|
||||||
|
},
|
||||||
|
WorkRegion.UK: {
|
||||||
|
'standard_hours_per_day': 8.0,
|
||||||
|
'standard_hours_per_week': 48.0, # UK has 48-hour week limit
|
||||||
|
'overtime_enabled': True,
|
||||||
|
'overtime_rate': 1.5,
|
||||||
|
'double_time_enabled': False,
|
||||||
|
'double_time_threshold': 12.0,
|
||||||
|
'double_time_rate': 2.0,
|
||||||
|
'require_breaks': True,
|
||||||
|
'break_duration_minutes': 20,
|
||||||
|
'break_after_hours': 6.0,
|
||||||
|
'weekly_overtime_threshold': 48.0,
|
||||||
|
'weekly_overtime_rate': 1.5,
|
||||||
|
'region_name': 'United Kingdom'
|
||||||
|
},
|
||||||
|
WorkRegion.CANADA: {
|
||||||
|
'standard_hours_per_day': 8.0,
|
||||||
|
'standard_hours_per_week': 40.0,
|
||||||
|
'overtime_enabled': True,
|
||||||
|
'overtime_rate': 1.5,
|
||||||
|
'double_time_enabled': False,
|
||||||
|
'double_time_threshold': 12.0,
|
||||||
|
'double_time_rate': 2.0,
|
||||||
|
'require_breaks': True,
|
||||||
|
'break_duration_minutes': 30,
|
||||||
|
'break_after_hours': 5.0,
|
||||||
|
'weekly_overtime_threshold': 40.0,
|
||||||
|
'weekly_overtime_rate': 1.5,
|
||||||
|
'region_name': 'Canada'
|
||||||
|
},
|
||||||
|
WorkRegion.AUSTRALIA: {
|
||||||
|
'standard_hours_per_day': 7.6, # 38-hour week / 5 days
|
||||||
|
'standard_hours_per_week': 38.0,
|
||||||
|
'overtime_enabled': True,
|
||||||
|
'overtime_rate': 1.5,
|
||||||
|
'double_time_enabled': True,
|
||||||
|
'double_time_threshold': 10.0,
|
||||||
|
'double_time_rate': 2.0,
|
||||||
|
'require_breaks': True,
|
||||||
|
'break_duration_minutes': 30,
|
||||||
|
'break_after_hours': 5.0,
|
||||||
|
'weekly_overtime_threshold': 38.0,
|
||||||
|
'weekly_overtime_rate': 1.5,
|
||||||
|
'region_name': 'Australia'
|
||||||
|
},
|
||||||
|
WorkRegion.OTHER: {
|
||||||
|
'standard_hours_per_day': 8.0,
|
||||||
|
'standard_hours_per_week': 40.0,
|
||||||
|
'overtime_enabled': False,
|
||||||
|
'overtime_rate': 1.5,
|
||||||
|
'double_time_enabled': False,
|
||||||
|
'double_time_threshold': 12.0,
|
||||||
|
'double_time_rate': 2.0,
|
||||||
|
'require_breaks': False,
|
||||||
|
'break_duration_minutes': 0,
|
||||||
|
'break_after_hours': 999.0,
|
||||||
|
'weekly_overtime_threshold': 40.0,
|
||||||
|
'weekly_overtime_rate': 1.5,
|
||||||
|
'region_name': 'Other'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return presets.get(region, presets[WorkRegion.OTHER])
|
||||||
99
models/dashboard.py
Normal file
99
models/dashboard.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Dashboard widget models
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
from . import db
|
||||||
|
from .enums import WidgetType, Role
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardWidget(db.Model):
|
||||||
|
"""User dashboard widget configuration"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
dashboard_id = db.Column(db.Integer, db.ForeignKey('user_dashboard.id'), nullable=False)
|
||||||
|
widget_type = db.Column(db.Enum(WidgetType), nullable=False)
|
||||||
|
|
||||||
|
# Grid position and size
|
||||||
|
grid_x = db.Column(db.Integer, nullable=False, default=0) # X position in grid
|
||||||
|
grid_y = db.Column(db.Integer, nullable=False, default=0) # Y position in grid
|
||||||
|
grid_width = db.Column(db.Integer, nullable=False, default=1) # Width in grid units
|
||||||
|
grid_height = db.Column(db.Integer, nullable=False, default=1) # Height in grid units
|
||||||
|
|
||||||
|
# Widget configuration
|
||||||
|
title = db.Column(db.String(100)) # Custom widget title
|
||||||
|
config = db.Column(db.Text) # JSON string for widget-specific configuration
|
||||||
|
refresh_interval = db.Column(db.Integer, default=60) # Refresh interval in seconds
|
||||||
|
|
||||||
|
# Widget state
|
||||||
|
is_visible = db.Column(db.Boolean, default=True)
|
||||||
|
is_minimized = db.Column(db.Boolean, default=False)
|
||||||
|
z_index = db.Column(db.Integer, default=1) # Stacking order
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<DashboardWidget {self.widget_type.value} ({self.grid_width}x{self.grid_height})>'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config_dict(self):
|
||||||
|
"""Parse widget configuration JSON"""
|
||||||
|
if self.config:
|
||||||
|
try:
|
||||||
|
return json.loads(self.config)
|
||||||
|
except:
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@config_dict.setter
|
||||||
|
def config_dict(self, value):
|
||||||
|
"""Set widget configuration as JSON"""
|
||||||
|
self.config = json.dumps(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetTemplate(db.Model):
|
||||||
|
"""Pre-defined widget templates for easy dashboard setup"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
widget_type = db.Column(db.Enum(WidgetType), nullable=False)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
description = db.Column(db.Text)
|
||||||
|
icon = db.Column(db.String(50)) # Icon name or emoji
|
||||||
|
|
||||||
|
# Default configuration
|
||||||
|
default_width = db.Column(db.Integer, default=1)
|
||||||
|
default_height = db.Column(db.Integer, default=1)
|
||||||
|
default_config = db.Column(db.Text) # JSON string for default widget configuration
|
||||||
|
|
||||||
|
# Access control
|
||||||
|
required_role = db.Column(db.Enum(Role), default=Role.TEAM_MEMBER)
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
|
||||||
|
# Categories for organization
|
||||||
|
category = db.Column(db.String(50), default='General') # Time, Projects, Tasks, Analytics, Team, Actions
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<WidgetTemplate {self.name} ({self.widget_type.value})>'
|
||||||
|
|
||||||
|
def can_user_access(self, user):
|
||||||
|
"""Check if user has required role to use this widget"""
|
||||||
|
if not self.is_active:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Define role hierarchy
|
||||||
|
role_hierarchy = {
|
||||||
|
Role.TEAM_MEMBER: 1,
|
||||||
|
Role.TEAM_LEADER: 2,
|
||||||
|
Role.SUPERVISOR: 3,
|
||||||
|
Role.ADMIN: 4,
|
||||||
|
Role.SYSTEM_ADMIN: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
user_level = role_hierarchy.get(user.role, 0)
|
||||||
|
required_level = role_hierarchy.get(self.required_role, 0)
|
||||||
|
|
||||||
|
return user_level >= required_level
|
||||||
115
models/enums.py
Normal file
115
models/enums.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Enum definitions for the TimeTrack application
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class Role(enum.Enum):
|
||||||
|
"""User role enumeration"""
|
||||||
|
TEAM_MEMBER = "Team Member"
|
||||||
|
TEAM_LEADER = "Team Leader"
|
||||||
|
SUPERVISOR = "Supervisor"
|
||||||
|
ADMIN = "Administrator" # Company-level admin
|
||||||
|
SYSTEM_ADMIN = "System Administrator" # System-wide admin
|
||||||
|
|
||||||
|
|
||||||
|
class AccountType(enum.Enum):
|
||||||
|
"""Account type for freelancer support"""
|
||||||
|
COMPANY_USER = "Company User"
|
||||||
|
FREELANCER = "Freelancer"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkRegion(enum.Enum):
|
||||||
|
"""Work region enumeration for different labor law compliance"""
|
||||||
|
USA = "United States"
|
||||||
|
CANADA = "Canada"
|
||||||
|
UK = "United Kingdom"
|
||||||
|
GERMANY = "Germany"
|
||||||
|
EU = "European Union"
|
||||||
|
AUSTRALIA = "Australia"
|
||||||
|
OTHER = "Other"
|
||||||
|
|
||||||
|
|
||||||
|
class CommentVisibility(enum.Enum):
|
||||||
|
"""Comment visibility levels"""
|
||||||
|
PRIVATE = "Private" # Only creator can see
|
||||||
|
TEAM = "Team" # Team members can see
|
||||||
|
COMPANY = "Company" # All company users can see
|
||||||
|
|
||||||
|
|
||||||
|
class TaskStatus(enum.Enum):
|
||||||
|
"""Task status enumeration"""
|
||||||
|
TODO = "To Do"
|
||||||
|
IN_PROGRESS = "In Progress"
|
||||||
|
IN_REVIEW = "In Review"
|
||||||
|
DONE = "Done"
|
||||||
|
CANCELLED = "Cancelled"
|
||||||
|
ARCHIVED = "Archived"
|
||||||
|
|
||||||
|
|
||||||
|
class TaskPriority(enum.Enum):
|
||||||
|
"""Task priority levels"""
|
||||||
|
LOW = "Low"
|
||||||
|
MEDIUM = "Medium"
|
||||||
|
HIGH = "High"
|
||||||
|
URGENT = "Urgent"
|
||||||
|
|
||||||
|
|
||||||
|
class SprintStatus(enum.Enum):
|
||||||
|
"""Sprint status enumeration"""
|
||||||
|
PLANNING = "Planning"
|
||||||
|
ACTIVE = "Active"
|
||||||
|
COMPLETED = "Completed"
|
||||||
|
CANCELLED = "Cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetType(enum.Enum):
|
||||||
|
"""Dashboard widget types"""
|
||||||
|
# Time Tracking Widgets
|
||||||
|
CURRENT_TIMER = "current_timer"
|
||||||
|
DAILY_SUMMARY = "daily_summary"
|
||||||
|
WEEKLY_CHART = "weekly_chart"
|
||||||
|
BREAK_REMINDER = "break_reminder"
|
||||||
|
TIME_SUMMARY = "Time Summary"
|
||||||
|
|
||||||
|
# Project Management Widgets
|
||||||
|
ACTIVE_PROJECTS = "active_projects"
|
||||||
|
PROJECT_PROGRESS = "project_progress"
|
||||||
|
PROJECT_ACTIVITY = "project_activity"
|
||||||
|
PROJECT_DEADLINES = "project_deadlines"
|
||||||
|
PROJECT_STATUS = "Project Status"
|
||||||
|
|
||||||
|
# Task Management Widgets
|
||||||
|
ASSIGNED_TASKS = "assigned_tasks"
|
||||||
|
TASK_PRIORITY = "task_priority"
|
||||||
|
TASK_CALENDAR = "task_calendar"
|
||||||
|
UPCOMING_TASKS = "upcoming_tasks"
|
||||||
|
TASK_LIST = "Task List"
|
||||||
|
|
||||||
|
# Sprint Widgets
|
||||||
|
SPRINT_OVERVIEW = "sprint_overview"
|
||||||
|
SPRINT_BURNDOWN = "sprint_burndown"
|
||||||
|
SPRINT_PROGRESS = "Sprint Progress"
|
||||||
|
|
||||||
|
# Team & Analytics Widgets
|
||||||
|
TEAM_WORKLOAD = "team_workload"
|
||||||
|
TEAM_PRESENCE = "team_presence"
|
||||||
|
TEAM_ACTIVITY = "Team Activity"
|
||||||
|
|
||||||
|
# Performance & Stats Widgets
|
||||||
|
PRODUCTIVITY_STATS = "productivity_stats"
|
||||||
|
TIME_DISTRIBUTION = "time_distribution"
|
||||||
|
PERSONAL_STATS = "Personal Stats"
|
||||||
|
|
||||||
|
# Action Widgets
|
||||||
|
QUICK_ACTIONS = "quick_actions"
|
||||||
|
RECENT_ACTIVITY = "recent_activity"
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetSize(enum.Enum):
|
||||||
|
"""Dashboard widget sizes"""
|
||||||
|
SMALL = "small" # 1x1
|
||||||
|
MEDIUM = "medium" # 2x1
|
||||||
|
LARGE = "large" # 2x2
|
||||||
|
WIDE = "wide" # 3x1 or full width
|
||||||
56
models/invitation.py
Normal file
56
models/invitation.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
Invitation model for company email invites
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from . import db
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyInvitation(db.Model):
|
||||||
|
"""Company invitation model for email-based registration"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||||
|
email = db.Column(db.String(120), nullable=False)
|
||||||
|
token = db.Column(db.String(64), unique=True, nullable=False)
|
||||||
|
role = db.Column(db.String(50), default='Team Member') # Role to assign when accepted
|
||||||
|
|
||||||
|
# Invitation metadata
|
||||||
|
invited_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
expires_at = db.Column(db.DateTime, nullable=False)
|
||||||
|
|
||||||
|
# Status tracking
|
||||||
|
accepted = db.Column(db.Boolean, default=False)
|
||||||
|
accepted_at = db.Column(db.DateTime)
|
||||||
|
accepted_by_user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
company = db.relationship('Company', backref='invitations')
|
||||||
|
invited_by = db.relationship('User', foreign_keys=[invited_by_id], backref='sent_invitations')
|
||||||
|
accepted_by = db.relationship('User', foreign_keys=[accepted_by_user_id], backref='accepted_invitation')
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super(CompanyInvitation, self).__init__(**kwargs)
|
||||||
|
if not self.token:
|
||||||
|
self.token = self.generate_token()
|
||||||
|
if not self.expires_at:
|
||||||
|
self.expires_at = datetime.now() + timedelta(days=7) # 7 days expiry
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_token():
|
||||||
|
"""Generate a secure random token"""
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
return ''.join(secrets.choice(alphabet) for _ in range(32))
|
||||||
|
|
||||||
|
def is_expired(self):
|
||||||
|
"""Check if invitation has expired"""
|
||||||
|
return datetime.now() > self.expires_at
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
"""Check if invitation is valid (not accepted and not expired)"""
|
||||||
|
return not self.accepted and not self.is_expired()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<CompanyInvitation {self.email} to {self.company.name}>'
|
||||||
86
models/project.py
Normal file
86
models/project.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
Project-related models
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from . import db
|
||||||
|
from .enums import Role
|
||||||
|
|
||||||
|
|
||||||
|
class Project(db.Model):
|
||||||
|
"""Project model for time tracking"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
code = db.Column(db.String(20), nullable=False) # Project code (e.g., PRJ001)
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
# Company association for multi-tenancy
|
||||||
|
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||||
|
|
||||||
|
# Foreign key to user who created the project (Admin/Supervisor)
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
|
# Optional team assignment - if set, only team members can log time to this project
|
||||||
|
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True)
|
||||||
|
|
||||||
|
# Project categorization
|
||||||
|
category_id = db.Column(db.Integer, db.ForeignKey('project_category.id'), nullable=True)
|
||||||
|
|
||||||
|
# Project dates
|
||||||
|
start_date = db.Column(db.Date, nullable=True)
|
||||||
|
end_date = db.Column(db.Date, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='created_projects')
|
||||||
|
team = db.relationship('Team', backref='projects')
|
||||||
|
time_entries = db.relationship('TimeEntry', backref='project', lazy=True)
|
||||||
|
category = db.relationship('ProjectCategory', back_populates='projects')
|
||||||
|
|
||||||
|
# Unique constraint per company
|
||||||
|
__table_args__ = (db.UniqueConstraint('company_id', 'code', name='uq_project_code_per_company'),)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Project {self.name} ({self.code})>'
|
||||||
|
|
||||||
|
def is_user_allowed(self, user):
|
||||||
|
"""Check if a user can log time to this project"""
|
||||||
|
# User must be in the same company
|
||||||
|
if user.company_id != self.company_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Admins and Supervisors can log time to any project in their company
|
||||||
|
if user.role in [Role.ADMIN, Role.SUPERVISOR]:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# If project is team-specific, only team members can log time
|
||||||
|
if self.team_id:
|
||||||
|
return user.team_id == self.team_id
|
||||||
|
|
||||||
|
# If no team restriction, any user in the company can log time
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCategory(db.Model):
|
||||||
|
"""Project category for organization"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(50), nullable=False)
|
||||||
|
description = db.Column(db.String(255))
|
||||||
|
color = db.Column(db.String(7), default='#3B82F6') # Hex color for UI
|
||||||
|
|
||||||
|
# Company association
|
||||||
|
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
projects = db.relationship('Project', back_populates='category')
|
||||||
|
company = db.relationship('Company', backref='project_categories')
|
||||||
|
|
||||||
|
# Unique constraint
|
||||||
|
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_category_name_per_company'),)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<ProjectCategory {self.name}>'
|
||||||
117
models/sprint.py
Normal file
117
models/sprint.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Sprint model for agile project management
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, date
|
||||||
|
from . import db
|
||||||
|
from .enums import SprintStatus, TaskStatus, Role
|
||||||
|
|
||||||
|
|
||||||
|
class Sprint(db.Model):
|
||||||
|
"""Sprint model for agile project management"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(200), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Sprint status
|
||||||
|
status = db.Column(db.Enum(SprintStatus), nullable=False, default=SprintStatus.PLANNING)
|
||||||
|
|
||||||
|
# Company association - sprints are company-scoped
|
||||||
|
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||||
|
|
||||||
|
# Optional project association - can be project-specific or company-wide
|
||||||
|
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True)
|
||||||
|
|
||||||
|
# Sprint timeline
|
||||||
|
start_date = db.Column(db.Date, nullable=False)
|
||||||
|
end_date = db.Column(db.Date, nullable=False)
|
||||||
|
|
||||||
|
# Sprint goals and metrics
|
||||||
|
goal = db.Column(db.Text, nullable=True) # Sprint goal description
|
||||||
|
capacity_hours = db.Column(db.Integer, nullable=True) # Planned capacity in hours
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
company = db.relationship('Company', backref='sprints')
|
||||||
|
project = db.relationship('Project', backref='sprints')
|
||||||
|
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||||
|
tasks = db.relationship('Task', backref='sprint', lazy=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Sprint {self.name}>'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_current(self):
|
||||||
|
"""Check if this sprint is currently active"""
|
||||||
|
today = date.today()
|
||||||
|
return (self.status == SprintStatus.ACTIVE and
|
||||||
|
self.start_date <= today <= self.end_date)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration_days(self):
|
||||||
|
"""Get sprint duration in days"""
|
||||||
|
return (self.end_date - self.start_date).days + 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def days_remaining(self):
|
||||||
|
"""Get remaining days in sprint"""
|
||||||
|
today = date.today()
|
||||||
|
if self.end_date < today:
|
||||||
|
return 0
|
||||||
|
elif self.start_date > today:
|
||||||
|
return self.duration_days
|
||||||
|
else:
|
||||||
|
return (self.end_date - today).days + 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def progress_percentage(self):
|
||||||
|
"""Calculate sprint progress percentage based on dates"""
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
if today < self.start_date:
|
||||||
|
return 0
|
||||||
|
elif today > self.end_date:
|
||||||
|
return 100
|
||||||
|
else:
|
||||||
|
total_days = self.duration_days
|
||||||
|
elapsed_days = (today - self.start_date).days + 1
|
||||||
|
return min(100, int((elapsed_days / total_days) * 100))
|
||||||
|
|
||||||
|
def get_task_summary(self):
|
||||||
|
"""Get summary of tasks in this sprint"""
|
||||||
|
total_tasks = len(self.tasks)
|
||||||
|
completed_tasks = len([t for t in self.tasks if t.status == TaskStatus.DONE])
|
||||||
|
in_progress_tasks = len([t for t in self.tasks if t.status == TaskStatus.IN_PROGRESS])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total': total_tasks,
|
||||||
|
'completed': completed_tasks,
|
||||||
|
'in_progress': in_progress_tasks,
|
||||||
|
'not_started': total_tasks - completed_tasks - in_progress_tasks,
|
||||||
|
'completion_percentage': int((completed_tasks / total_tasks) * 100) if total_tasks > 0 else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def can_user_access(self, user):
|
||||||
|
"""Check if user can access this sprint"""
|
||||||
|
# Must be in same company
|
||||||
|
if self.company_id != user.company_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If sprint is project-specific, check project access
|
||||||
|
if self.project_id:
|
||||||
|
return self.project.is_user_allowed(user)
|
||||||
|
|
||||||
|
# Company-wide sprints can be accessed by all company users
|
||||||
|
return True
|
||||||
|
|
||||||
|
def can_user_modify(self, user):
|
||||||
|
"""Check if user can modify this sprint"""
|
||||||
|
if not self.can_user_access(user):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Only admins and supervisors can modify sprints
|
||||||
|
return user.role in [Role.ADMIN, Role.SUPERVISOR]
|
||||||
132
models/system.py
Normal file
132
models/system.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""
|
||||||
|
System-related models
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from . import db
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSettings(db.Model):
|
||||||
|
"""Key-value store for system-wide settings"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
key = db.Column(db.String(50), unique=True, nullable=False)
|
||||||
|
value = db.Column(db.String(255), nullable=False)
|
||||||
|
description = db.Column(db.String(255))
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<SystemSettings {self.key}={self.value}>'
|
||||||
|
|
||||||
|
|
||||||
|
class BrandingSettings(db.Model):
|
||||||
|
"""Branding and customization settings"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
app_name = db.Column(db.String(100), nullable=False, default='Time Tracker')
|
||||||
|
logo_filename = db.Column(db.String(255), nullable=True) # Filename of uploaded logo
|
||||||
|
logo_alt_text = db.Column(db.String(255), nullable=True, default='Logo')
|
||||||
|
favicon_filename = db.Column(db.String(255), nullable=True) # Filename of uploaded favicon
|
||||||
|
primary_color = db.Column(db.String(7), nullable=True, default='#007bff') # Hex color
|
||||||
|
|
||||||
|
# Imprint/Legal page settings
|
||||||
|
imprint_enabled = db.Column(db.Boolean, default=False) # Enable/disable imprint page
|
||||||
|
imprint_title = db.Column(db.String(200), nullable=True, default='Imprint') # Page title
|
||||||
|
imprint_content = db.Column(db.Text, nullable=True) # HTML content for imprint page
|
||||||
|
|
||||||
|
# Meta fields
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
updated_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
updated_by = db.relationship('User', foreign_keys=[updated_by_id])
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<BrandingSettings {self.app_name}>'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_settings():
|
||||||
|
"""Get current branding settings or create defaults"""
|
||||||
|
settings = BrandingSettings.query.first()
|
||||||
|
if not settings:
|
||||||
|
settings = BrandingSettings(
|
||||||
|
app_name='Time Tracker',
|
||||||
|
logo_alt_text='Application Logo'
|
||||||
|
)
|
||||||
|
db.session.add(settings)
|
||||||
|
db.session.commit()
|
||||||
|
return settings
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_current():
|
||||||
|
"""Alias for get_settings() for backward compatibility"""
|
||||||
|
return BrandingSettings.get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class SystemEvent(db.Model):
|
||||||
|
"""System event logging for audit and monitoring"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
event_type = db.Column(db.String(50), nullable=False) # e.g., 'login', 'logout', 'user_created', 'system_error'
|
||||||
|
event_category = db.Column(db.String(30), nullable=False) # e.g., 'auth', 'user_management', 'system', 'error'
|
||||||
|
description = db.Column(db.Text, nullable=False)
|
||||||
|
severity = db.Column(db.String(20), default='info') # 'info', 'warning', 'error', 'critical'
|
||||||
|
timestamp = db.Column(db.DateTime, default=datetime.now, nullable=False)
|
||||||
|
|
||||||
|
# Optional associations
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||||
|
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=True)
|
||||||
|
|
||||||
|
# Additional metadata (JSON string)
|
||||||
|
event_metadata = db.Column(db.Text, nullable=True) # Store additional event data as JSON
|
||||||
|
|
||||||
|
# IP address and user agent for security tracking
|
||||||
|
ip_address = db.Column(db.String(45), nullable=True) # IPv6 compatible
|
||||||
|
user_agent = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = db.relationship('User', backref='system_events')
|
||||||
|
company = db.relationship('Company', backref='system_events')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<SystemEvent {self.event_type}: {self.description[:50]}>'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def log_event(event_type, description, event_category='system', severity='info',
|
||||||
|
user_id=None, company_id=None, event_metadata=None, ip_address=None, user_agent=None):
|
||||||
|
"""Helper method to log system events"""
|
||||||
|
event = SystemEvent(
|
||||||
|
event_type=event_type,
|
||||||
|
event_category=event_category,
|
||||||
|
description=description,
|
||||||
|
severity=severity,
|
||||||
|
user_id=user_id,
|
||||||
|
company_id=company_id,
|
||||||
|
event_metadata=event_metadata,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent
|
||||||
|
)
|
||||||
|
db.session.add(event)
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
# Log to application logger if DB logging fails
|
||||||
|
import logging
|
||||||
|
logging.error(f"Failed to log system event: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_recent_events(days=7, limit=100):
|
||||||
|
"""Get recent system events from the last N days"""
|
||||||
|
since = datetime.now() - timedelta(days=days)
|
||||||
|
return SystemEvent.query.filter(
|
||||||
|
SystemEvent.timestamp >= since
|
||||||
|
).order_by(SystemEvent.timestamp.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cleanup_old_events(days=90):
|
||||||
|
"""Delete system events older than specified days"""
|
||||||
|
cutoff_date = datetime.now() - timedelta(days=days)
|
||||||
|
deleted_count = SystemEvent.query.filter(
|
||||||
|
SystemEvent.timestamp < cutoff_date
|
||||||
|
).delete()
|
||||||
|
db.session.commit()
|
||||||
|
return deleted_count
|
||||||
245
models/task.py
Normal file
245
models/task.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"""
|
||||||
|
Task-related models
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from . import db
|
||||||
|
from .enums import TaskStatus, TaskPriority, CommentVisibility, Role
|
||||||
|
|
||||||
|
|
||||||
|
class Task(db.Model):
|
||||||
|
"""Task model for project management"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
task_number = db.Column(db.String(20), nullable=False, unique=True) # e.g., "TSK-001", "TSK-002"
|
||||||
|
name = db.Column(db.String(200), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Task properties
|
||||||
|
status = db.Column(db.Enum(TaskStatus), default=TaskStatus.TODO)
|
||||||
|
priority = db.Column(db.Enum(TaskPriority), default=TaskPriority.MEDIUM)
|
||||||
|
estimated_hours = db.Column(db.Float, nullable=True) # Estimated time to complete
|
||||||
|
|
||||||
|
# Project association
|
||||||
|
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
|
||||||
|
|
||||||
|
# Sprint association (optional)
|
||||||
|
sprint_id = db.Column(db.Integer, db.ForeignKey('sprint.id'), nullable=True)
|
||||||
|
|
||||||
|
# Task assignment
|
||||||
|
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||||
|
|
||||||
|
# Task dates
|
||||||
|
start_date = db.Column(db.Date, nullable=True)
|
||||||
|
due_date = db.Column(db.Date, nullable=True)
|
||||||
|
completed_date = db.Column(db.Date, nullable=True)
|
||||||
|
archived_date = db.Column(db.Date, nullable=True)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
project = db.relationship('Project', backref='tasks')
|
||||||
|
assigned_to = db.relationship('User', foreign_keys=[assigned_to_id], backref='assigned_tasks')
|
||||||
|
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||||
|
subtasks = db.relationship('SubTask', backref='parent_task', lazy=True, cascade='all, delete-orphan')
|
||||||
|
time_entries = db.relationship('TimeEntry', backref='task', lazy=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Task {self.name} ({self.status.value})>'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def progress_percentage(self):
|
||||||
|
"""Calculate task progress based on subtasks completion"""
|
||||||
|
if not self.subtasks:
|
||||||
|
return 100 if self.status == TaskStatus.DONE else 0
|
||||||
|
|
||||||
|
completed_subtasks = sum(1 for subtask in self.subtasks if subtask.status == TaskStatus.DONE)
|
||||||
|
return int((completed_subtasks / len(self.subtasks)) * 100)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_time_logged(self):
|
||||||
|
"""Calculate total time logged to this task (in seconds)"""
|
||||||
|
return sum(entry.duration or 0 for entry in self.time_entries if entry.duration)
|
||||||
|
|
||||||
|
def can_user_access(self, user):
|
||||||
|
"""Check if a user can access this task"""
|
||||||
|
return self.project.is_user_allowed(user)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_task_number(cls, company_id):
|
||||||
|
"""Generate next task number for the company"""
|
||||||
|
# Get the highest task number for this company
|
||||||
|
last_task = cls.query.join(Project).filter(
|
||||||
|
Project.company_id == company_id,
|
||||||
|
cls.task_number.like('TSK-%')
|
||||||
|
).order_by(cls.task_number.desc()).first()
|
||||||
|
|
||||||
|
if last_task and last_task.task_number:
|
||||||
|
try:
|
||||||
|
# Extract number from TSK-XXX format
|
||||||
|
last_num = int(last_task.task_number.split('-')[1])
|
||||||
|
return f"TSK-{last_num + 1:03d}"
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return "TSK-001"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def blocked_by_tasks(self):
|
||||||
|
"""Get tasks that are blocking this task"""
|
||||||
|
return [dep.blocking_task for dep in self.blocked_by_dependencies]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def blocking_tasks(self):
|
||||||
|
"""Get tasks that this task is blocking"""
|
||||||
|
return [dep.blocked_task for dep in self.blocking_dependencies]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskDependency(db.Model):
|
||||||
|
"""Track dependencies between tasks"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
||||||
|
# The task that is blocked (cannot start until blocking task is done)
|
||||||
|
blocked_task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
|
||||||
|
|
||||||
|
# The task that is blocking (must be completed before blocked task can start)
|
||||||
|
blocking_task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
|
||||||
|
|
||||||
|
# Dependency type (for future extension)
|
||||||
|
dependency_type = db.Column(db.String(50), default='blocks', nullable=False) # 'blocks', 'subtask', etc.
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
blocked_task = db.relationship('Task', foreign_keys=[blocked_task_id],
|
||||||
|
backref=db.backref('blocked_by_dependencies', cascade='all, delete-orphan'))
|
||||||
|
blocking_task = db.relationship('Task', foreign_keys=[blocking_task_id],
|
||||||
|
backref=db.backref('blocking_dependencies', cascade='all, delete-orphan'))
|
||||||
|
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||||
|
|
||||||
|
# Ensure a task doesn't block itself and prevent duplicate dependencies
|
||||||
|
__table_args__ = (
|
||||||
|
db.CheckConstraint('blocked_task_id != blocking_task_id', name='no_self_blocking'),
|
||||||
|
db.UniqueConstraint('blocked_task_id', 'blocking_task_id', name='unique_dependency'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<TaskDependency {self.blocking_task_id} blocks {self.blocked_task_id}>'
|
||||||
|
|
||||||
|
|
||||||
|
class SubTask(db.Model):
|
||||||
|
"""Subtask model for breaking down tasks"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(200), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# SubTask properties
|
||||||
|
status = db.Column(db.Enum(TaskStatus), default=TaskStatus.TODO)
|
||||||
|
priority = db.Column(db.Enum(TaskPriority), default=TaskPriority.MEDIUM)
|
||||||
|
estimated_hours = db.Column(db.Float, nullable=True)
|
||||||
|
|
||||||
|
# Parent task association
|
||||||
|
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
|
||||||
|
|
||||||
|
# Assignment
|
||||||
|
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||||
|
|
||||||
|
# Dates
|
||||||
|
start_date = db.Column(db.Date, nullable=True)
|
||||||
|
due_date = db.Column(db.Date, nullable=True)
|
||||||
|
completed_date = db.Column(db.Date, nullable=True)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
assigned_to = db.relationship('User', foreign_keys=[assigned_to_id], backref='assigned_subtasks')
|
||||||
|
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||||
|
time_entries = db.relationship('TimeEntry', backref='subtask', lazy=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<SubTask {self.name} ({self.status.value})>'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_time_logged(self):
|
||||||
|
"""Calculate total time logged to this subtask (in seconds)"""
|
||||||
|
return sum(entry.duration or 0 for entry in self.time_entries if entry.duration)
|
||||||
|
|
||||||
|
def can_user_access(self, user):
|
||||||
|
"""Check if a user can access this subtask"""
|
||||||
|
return self.parent_task.can_user_access(user)
|
||||||
|
|
||||||
|
|
||||||
|
class Comment(db.Model):
|
||||||
|
"""Comment model for task discussions"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
content = db.Column(db.Text, nullable=False)
|
||||||
|
|
||||||
|
# Task association
|
||||||
|
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
|
||||||
|
|
||||||
|
# Parent comment for thread support
|
||||||
|
parent_comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'), nullable=True)
|
||||||
|
|
||||||
|
# Visibility setting
|
||||||
|
visibility = db.Column(db.Enum(CommentVisibility), default=CommentVisibility.COMPANY)
|
||||||
|
|
||||||
|
# Edit tracking
|
||||||
|
is_edited = db.Column(db.Boolean, default=False)
|
||||||
|
edited_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
task = db.relationship('Task', backref=db.backref('comments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||||
|
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='comments')
|
||||||
|
replies = db.relationship('Comment', backref=db.backref('parent_comment', remote_side=[id]))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Comment {self.id} on Task {self.task_id}>'
|
||||||
|
|
||||||
|
def can_user_view(self, user):
|
||||||
|
"""Check if a user can view this comment based on visibility settings"""
|
||||||
|
# First check if user can access the task
|
||||||
|
if not self.task.can_user_access(user):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check visibility rules
|
||||||
|
if self.visibility == CommentVisibility.PRIVATE:
|
||||||
|
return user.id == self.created_by_id
|
||||||
|
elif self.visibility == CommentVisibility.TEAM:
|
||||||
|
# User must be in the same team as the task's project
|
||||||
|
if self.task.project.team_id:
|
||||||
|
return user.team_id == self.task.project.team_id
|
||||||
|
return True # If no team restriction, all company users can see
|
||||||
|
else: # CommentVisibility.COMPANY
|
||||||
|
return True # All company users can see
|
||||||
|
|
||||||
|
def can_user_edit(self, user):
|
||||||
|
"""Check if a user can edit this comment"""
|
||||||
|
# Only the creator can edit their own comments
|
||||||
|
return user.id == self.created_by_id
|
||||||
|
|
||||||
|
def can_user_delete(self, user):
|
||||||
|
"""Check if a user can delete this comment"""
|
||||||
|
# Creator can delete their own comments
|
||||||
|
if user.id == self.created_by_id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Admins can delete any comment in their company
|
||||||
|
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Team leaders can delete comments on their team's tasks
|
||||||
|
if user.role == Role.TEAM_LEADER and self.task.project.team_id == user.team_id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
26
models/team.py
Normal file
26
models/team.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""
|
||||||
|
Team model
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from . import db
|
||||||
|
|
||||||
|
|
||||||
|
class Team(db.Model):
|
||||||
|
"""Team model for organizing users"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
description = db.Column(db.String(255))
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
|
||||||
|
# Company association for multi-tenancy
|
||||||
|
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||||
|
|
||||||
|
# Relationship with users (one team has many users)
|
||||||
|
users = db.relationship('User', backref='team', lazy=True)
|
||||||
|
|
||||||
|
# Unique constraint per company
|
||||||
|
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_team_name_per_company'),)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Team {self.name}>'
|
||||||
32
models/time_entry.py
Normal file
32
models/time_entry.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
Time entry model for tracking work hours
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from . import db
|
||||||
|
|
||||||
|
|
||||||
|
class TimeEntry(db.Model):
|
||||||
|
"""Time entry model for tracking work hours"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
arrival_time = db.Column(db.DateTime, nullable=False)
|
||||||
|
departure_time = db.Column(db.DateTime, nullable=True)
|
||||||
|
duration = db.Column(db.Integer, nullable=True) # Duration in seconds
|
||||||
|
is_paused = db.Column(db.Boolean, default=False)
|
||||||
|
pause_start_time = db.Column(db.DateTime, nullable=True)
|
||||||
|
total_break_duration = db.Column(db.Integer, default=0) # Total break duration in seconds
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||||
|
|
||||||
|
# Project association - nullable for backward compatibility
|
||||||
|
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True)
|
||||||
|
|
||||||
|
# Task/SubTask associations - nullable for backward compatibility
|
||||||
|
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True)
|
||||||
|
subtask_id = db.Column(db.Integer, db.ForeignKey('sub_task.id'), nullable=True)
|
||||||
|
|
||||||
|
# Optional notes/description for the time entry
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
project_info = f" (Project: {self.project.code})" if self.project else ""
|
||||||
|
return f'<TimeEntry {self.id}: {self.arrival_time} - {self.departure_time}{project_info}>'
|
||||||
203
models/user.py
Normal file
203
models/user.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
User-related models
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
import secrets
|
||||||
|
from . import db
|
||||||
|
from .enums import Role, AccountType, WidgetType, WidgetSize
|
||||||
|
|
||||||
|
|
||||||
|
class User(db.Model):
|
||||||
|
"""User model with multi-tenancy and role-based access"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(80), nullable=False)
|
||||||
|
email = db.Column(db.String(120), nullable=True)
|
||||||
|
password_hash = db.Column(db.String(128))
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Company association for multi-tenancy
|
||||||
|
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||||
|
|
||||||
|
# Email verification fields
|
||||||
|
is_verified = db.Column(db.Boolean, default=False)
|
||||||
|
verification_token = db.Column(db.String(100), unique=True, nullable=True)
|
||||||
|
token_expiry = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
# New field for blocking users
|
||||||
|
is_blocked = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
|
# New fields for role and team
|
||||||
|
role = db.Column(db.Enum(Role, values_callable=lambda obj: [e.value for e in obj]), default=Role.TEAM_MEMBER)
|
||||||
|
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True)
|
||||||
|
|
||||||
|
# Freelancer support
|
||||||
|
account_type = db.Column(db.Enum(AccountType, values_callable=lambda obj: [e.value for e in obj]), default=AccountType.COMPANY_USER)
|
||||||
|
business_name = db.Column(db.String(100), nullable=True) # Optional business name for freelancers
|
||||||
|
|
||||||
|
# Unique constraints per company
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint('company_id', 'username', name='uq_user_username_per_company'),
|
||||||
|
db.UniqueConstraint('company_id', 'email', name='uq_user_email_per_company'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Two-Factor Authentication fields
|
||||||
|
two_factor_enabled = db.Column(db.Boolean, default=False)
|
||||||
|
two_factor_secret = db.Column(db.String(32), nullable=True) # Base32 encoded secret
|
||||||
|
|
||||||
|
# Avatar field
|
||||||
|
avatar_url = db.Column(db.String(255), nullable=True) # URL to user's avatar image
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
time_entries = db.relationship('TimeEntry', backref='user', lazy=True)
|
||||||
|
work_config = db.relationship('WorkConfig', backref='user', lazy=True, uselist=False)
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
self.password_hash = generate_password_hash(password)
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
def generate_verification_token(self):
|
||||||
|
"""Generate a verification token that expires in 24 hours"""
|
||||||
|
self.verification_token = secrets.token_urlsafe(32)
|
||||||
|
self.token_expiry = datetime.utcnow() + timedelta(hours=24)
|
||||||
|
return self.verification_token
|
||||||
|
|
||||||
|
def verify_token(self, token):
|
||||||
|
"""Verify the token and mark user as verified if valid"""
|
||||||
|
if token == self.verification_token and self.token_expiry > datetime.utcnow():
|
||||||
|
self.is_verified = True
|
||||||
|
self.verification_token = None
|
||||||
|
self.token_expiry = None
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def generate_2fa_secret(self):
|
||||||
|
"""Generate a new 2FA secret"""
|
||||||
|
import pyotp
|
||||||
|
self.two_factor_secret = pyotp.random_base32()
|
||||||
|
return self.two_factor_secret
|
||||||
|
|
||||||
|
def get_2fa_uri(self, issuer_name=None):
|
||||||
|
"""Get the provisioning URI for QR code generation"""
|
||||||
|
if not self.two_factor_secret:
|
||||||
|
return None
|
||||||
|
import pyotp
|
||||||
|
totp = pyotp.TOTP(self.two_factor_secret)
|
||||||
|
if issuer_name is None:
|
||||||
|
issuer_name = "Time Tracker" # Default fallback
|
||||||
|
return totp.provisioning_uri(
|
||||||
|
name=self.email,
|
||||||
|
issuer_name=issuer_name
|
||||||
|
)
|
||||||
|
|
||||||
|
def verify_2fa_token(self, token, allow_setup=False):
|
||||||
|
"""Verify a 2FA token"""
|
||||||
|
if not self.two_factor_secret:
|
||||||
|
return False
|
||||||
|
# During setup, allow verification even if 2FA isn't enabled yet
|
||||||
|
if not allow_setup and not self.two_factor_enabled:
|
||||||
|
return False
|
||||||
|
import pyotp
|
||||||
|
totp = pyotp.TOTP(self.two_factor_secret)
|
||||||
|
return totp.verify(token, valid_window=1) # Allow 1 window tolerance
|
||||||
|
|
||||||
|
def get_avatar_url(self, size=40):
|
||||||
|
"""Get user's avatar URL or generate a default one"""
|
||||||
|
if self.avatar_url:
|
||||||
|
return self.avatar_url
|
||||||
|
|
||||||
|
# Generate a default avatar using DiceBear Avatars (similar to GitHub's identicons)
|
||||||
|
# Using initials style for a clean, professional look
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
# Create a hash from username for consistent colors
|
||||||
|
hash_input = f"{self.username}_{self.id}".encode('utf-8')
|
||||||
|
hash_hex = hashlib.md5(hash_input).hexdigest()
|
||||||
|
|
||||||
|
# Use DiceBear API for avatar generation
|
||||||
|
# For initials style, we need to provide the actual initials
|
||||||
|
initials = self.get_initials()
|
||||||
|
|
||||||
|
# Generate avatar URL with initials
|
||||||
|
# Using a color based on the hash for consistency
|
||||||
|
bg_colors = ['0ea5e9', '8b5cf6', 'ec4899', 'f59e0b', '10b981', 'ef4444', '3b82f6', '6366f1']
|
||||||
|
color_index = int(hash_hex[:2], 16) % len(bg_colors)
|
||||||
|
bg_color = bg_colors[color_index]
|
||||||
|
|
||||||
|
avatar_url = f"https://api.dicebear.com/7.x/initials/svg?seed={initials}&size={size}&backgroundColor={bg_color}&fontSize=50"
|
||||||
|
|
||||||
|
return avatar_url
|
||||||
|
|
||||||
|
def get_initials(self):
|
||||||
|
"""Get user initials for avatar display"""
|
||||||
|
parts = self.username.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
return f"{parts[0][0]}{parts[-1][0]}".upper()
|
||||||
|
elif self.username:
|
||||||
|
return self.username[:2].upper()
|
||||||
|
return "??"
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<User {self.username}>'
|
||||||
|
|
||||||
|
|
||||||
|
class UserPreferences(db.Model):
|
||||||
|
"""User preferences and settings"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), unique=True, nullable=False)
|
||||||
|
|
||||||
|
# UI preferences
|
||||||
|
theme = db.Column(db.String(20), default='light')
|
||||||
|
language = db.Column(db.String(10), default='en')
|
||||||
|
timezone = db.Column(db.String(50), default='UTC')
|
||||||
|
date_format = db.Column(db.String(20), default='YYYY-MM-DD')
|
||||||
|
time_format = db.Column(db.String(10), default='24h')
|
||||||
|
|
||||||
|
# Notification preferences
|
||||||
|
email_notifications = db.Column(db.Boolean, default=True)
|
||||||
|
email_daily_summary = db.Column(db.Boolean, default=False)
|
||||||
|
email_weekly_summary = db.Column(db.Boolean, default=True)
|
||||||
|
|
||||||
|
# Time tracking preferences
|
||||||
|
default_project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True)
|
||||||
|
timer_reminder_enabled = db.Column(db.Boolean, default=True)
|
||||||
|
timer_reminder_interval = db.Column(db.Integer, default=60) # Minutes
|
||||||
|
|
||||||
|
# Dashboard preferences
|
||||||
|
dashboard_layout = db.Column(db.JSON, nullable=True) # Store custom dashboard layout
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = db.relationship('User', backref=db.backref('preferences', uselist=False))
|
||||||
|
default_project = db.relationship('Project')
|
||||||
|
|
||||||
|
|
||||||
|
class UserDashboard(db.Model):
|
||||||
|
"""User's dashboard configuration"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
name = db.Column(db.String(100), default='My Dashboard')
|
||||||
|
is_default = db.Column(db.Boolean, default=True)
|
||||||
|
layout_config = db.Column(db.Text) # JSON string for grid layout configuration
|
||||||
|
|
||||||
|
# Dashboard settings
|
||||||
|
grid_columns = db.Column(db.Integer, default=6) # Number of grid columns
|
||||||
|
theme = db.Column(db.String(20), default='light') # light, dark, auto
|
||||||
|
auto_refresh = db.Column(db.Integer, default=300) # Auto-refresh interval in seconds
|
||||||
|
|
||||||
|
# Additional configuration (from new model)
|
||||||
|
layout = db.Column(db.JSON, nullable=True) # Grid layout configuration (alternative format)
|
||||||
|
is_locked = db.Column(db.Boolean, default=False) # Prevent accidental changes
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = db.relationship('User', backref=db.backref('dashboards', lazy='dynamic'))
|
||||||
|
widgets = db.relationship('DashboardWidget', backref='dashboard', lazy=True, cascade='all, delete')
|
||||||
31
models/work_config.py
Normal file
31
models/work_config.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Work configuration model
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from . import db
|
||||||
|
|
||||||
|
|
||||||
|
class WorkConfig(db.Model):
|
||||||
|
"""User-specific work configuration settings"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
work_hours_per_day = db.Column(db.Float, default=8.0) # Default 8 hours
|
||||||
|
mandatory_break_minutes = db.Column(db.Integer, default=30) # Default 30 minutes
|
||||||
|
break_threshold_hours = db.Column(db.Float, default=6.0) # Work hours that trigger mandatory break
|
||||||
|
additional_break_minutes = db.Column(db.Integer, default=15) # Default 15 minutes for additional break
|
||||||
|
additional_break_threshold_hours = db.Column(db.Float, default=9.0) # Work hours that trigger additional break
|
||||||
|
|
||||||
|
# Time rounding settings
|
||||||
|
time_rounding_minutes = db.Column(db.Integer, default=0) # 0 = no rounding, 15 = 15 min, 30 = 30 min
|
||||||
|
round_to_nearest = db.Column(db.Boolean, default=True) # True = round to nearest, False = round up
|
||||||
|
|
||||||
|
# Date/time format settings
|
||||||
|
time_format_24h = db.Column(db.Boolean, default=True) # True = 24h, False = 12h (AM/PM)
|
||||||
|
date_format = db.Column(db.String(20), default='ISO') # ISO, US, EU, etc.
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<WorkConfig {self.id}: {self.work_hours_per_day}h/day, {self.mandatory_break_minutes}min break>'
|
||||||
189
routes/announcements.py
Normal file
189
routes/announcements.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"""
|
||||||
|
Announcement management routes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, g
|
||||||
|
from models import db, Announcement, Company, User, Role
|
||||||
|
from routes.auth import system_admin_required
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
announcements_bp = Blueprint('announcements', __name__, url_prefix='/system-admin/announcements')
|
||||||
|
|
||||||
|
|
||||||
|
@announcements_bp.route('')
|
||||||
|
@system_admin_required
|
||||||
|
def index():
|
||||||
|
"""System Admin: Manage announcements"""
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = 20
|
||||||
|
|
||||||
|
announcements = Announcement.query.order_by(Announcement.created_at.desc()).paginate(
|
||||||
|
page=page, per_page=per_page, error_out=False)
|
||||||
|
|
||||||
|
return render_template('system_admin_announcements.html',
|
||||||
|
title='System Admin - Announcements',
|
||||||
|
announcements=announcements)
|
||||||
|
|
||||||
|
|
||||||
|
@announcements_bp.route('/new', methods=['GET', 'POST'])
|
||||||
|
@system_admin_required
|
||||||
|
def create():
|
||||||
|
"""System Admin: Create new announcement"""
|
||||||
|
if request.method == 'POST':
|
||||||
|
title = request.form.get('title')
|
||||||
|
content = request.form.get('content')
|
||||||
|
announcement_type = request.form.get('announcement_type', 'info')
|
||||||
|
is_urgent = request.form.get('is_urgent') == 'on'
|
||||||
|
is_active = request.form.get('is_active') == 'on'
|
||||||
|
|
||||||
|
# Handle date fields
|
||||||
|
start_date = request.form.get('start_date')
|
||||||
|
end_date = request.form.get('end_date')
|
||||||
|
|
||||||
|
start_datetime = None
|
||||||
|
end_datetime = None
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
try:
|
||||||
|
start_datetime = datetime.strptime(start_date, '%Y-%m-%dT%H:%M')
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
try:
|
||||||
|
end_datetime = datetime.strptime(end_date, '%Y-%m-%dT%H:%M')
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Handle targeting
|
||||||
|
target_all_users = request.form.get('target_all_users') == 'on'
|
||||||
|
target_roles = None
|
||||||
|
target_companies = None
|
||||||
|
|
||||||
|
if not target_all_users:
|
||||||
|
selected_roles = request.form.getlist('target_roles')
|
||||||
|
selected_companies = request.form.getlist('target_companies')
|
||||||
|
|
||||||
|
if selected_roles:
|
||||||
|
target_roles = json.dumps(selected_roles)
|
||||||
|
|
||||||
|
if selected_companies:
|
||||||
|
target_companies = json.dumps([int(c) for c in selected_companies])
|
||||||
|
|
||||||
|
announcement = Announcement(
|
||||||
|
title=title,
|
||||||
|
content=content,
|
||||||
|
announcement_type=announcement_type,
|
||||||
|
is_urgent=is_urgent,
|
||||||
|
is_active=is_active,
|
||||||
|
start_date=start_datetime,
|
||||||
|
end_date=end_datetime,
|
||||||
|
target_all_users=target_all_users,
|
||||||
|
target_roles=target_roles,
|
||||||
|
target_companies=target_companies,
|
||||||
|
created_by_id=g.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(announcement)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Announcement created successfully.', 'success')
|
||||||
|
return redirect(url_for('announcements.index'))
|
||||||
|
|
||||||
|
# Get roles and companies for targeting options
|
||||||
|
roles = [role.value for role in Role]
|
||||||
|
companies = Company.query.order_by(Company.name).all()
|
||||||
|
|
||||||
|
return render_template('system_admin_announcement_form.html',
|
||||||
|
title='Create Announcement',
|
||||||
|
announcement=None,
|
||||||
|
roles=roles,
|
||||||
|
companies=companies)
|
||||||
|
|
||||||
|
|
||||||
|
@announcements_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||||
|
@system_admin_required
|
||||||
|
def edit(id):
|
||||||
|
"""System Admin: Edit announcement"""
|
||||||
|
announcement = Announcement.query.get_or_404(id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
announcement.title = request.form.get('title')
|
||||||
|
announcement.content = request.form.get('content')
|
||||||
|
announcement.announcement_type = request.form.get('announcement_type', 'info')
|
||||||
|
announcement.is_urgent = request.form.get('is_urgent') == 'on'
|
||||||
|
announcement.is_active = request.form.get('is_active') == 'on'
|
||||||
|
|
||||||
|
# Handle date fields
|
||||||
|
start_date = request.form.get('start_date')
|
||||||
|
end_date = request.form.get('end_date')
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
try:
|
||||||
|
announcement.start_date = datetime.strptime(start_date, '%Y-%m-%dT%H:%M')
|
||||||
|
except ValueError:
|
||||||
|
announcement.start_date = None
|
||||||
|
else:
|
||||||
|
announcement.start_date = None
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
try:
|
||||||
|
announcement.end_date = datetime.strptime(end_date, '%Y-%m-%dT%H:%M')
|
||||||
|
except ValueError:
|
||||||
|
announcement.end_date = None
|
||||||
|
else:
|
||||||
|
announcement.end_date = None
|
||||||
|
|
||||||
|
# Handle targeting
|
||||||
|
announcement.target_all_users = request.form.get('target_all_users') == 'on'
|
||||||
|
|
||||||
|
if not announcement.target_all_users:
|
||||||
|
selected_roles = request.form.getlist('target_roles')
|
||||||
|
selected_companies = request.form.getlist('target_companies')
|
||||||
|
|
||||||
|
if selected_roles:
|
||||||
|
announcement.target_roles = json.dumps(selected_roles)
|
||||||
|
else:
|
||||||
|
announcement.target_roles = None
|
||||||
|
|
||||||
|
if selected_companies:
|
||||||
|
announcement.target_companies = json.dumps([int(c) for c in selected_companies])
|
||||||
|
else:
|
||||||
|
announcement.target_companies = None
|
||||||
|
else:
|
||||||
|
announcement.target_roles = None
|
||||||
|
announcement.target_companies = None
|
||||||
|
|
||||||
|
announcement.updated_at = datetime.now()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Announcement updated successfully.', 'success')
|
||||||
|
return redirect(url_for('announcements.index'))
|
||||||
|
|
||||||
|
# Get roles and companies for targeting options
|
||||||
|
roles = [role.value for role in Role]
|
||||||
|
companies = Company.query.order_by(Company.name).all()
|
||||||
|
|
||||||
|
return render_template('system_admin_announcement_form.html',
|
||||||
|
title='Edit Announcement',
|
||||||
|
announcement=announcement,
|
||||||
|
roles=roles,
|
||||||
|
companies=companies)
|
||||||
|
|
||||||
|
|
||||||
|
@announcements_bp.route('/<int:id>/delete', methods=['POST'])
|
||||||
|
@system_admin_required
|
||||||
|
def delete(id):
|
||||||
|
"""System Admin: Delete announcement"""
|
||||||
|
announcement = Announcement.query.get_or_404(id)
|
||||||
|
|
||||||
|
db.session.delete(announcement)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Announcement deleted successfully.', 'success')
|
||||||
|
return redirect(url_for('announcements.index'))
|
||||||
100
routes/auth.py
Normal file
100
routes/auth.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
Authentication decorators for route protection
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
|
from flask import g, redirect, url_for, flash, request
|
||||||
|
from models import Role, Company
|
||||||
|
|
||||||
|
def login_required(f):
|
||||||
|
"""Decorator to require login for routes"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if g.user is None:
|
||||||
|
return redirect(url_for('login', next=request.url))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
def role_required(min_role):
|
||||||
|
"""Decorator to require a minimum role for routes"""
|
||||||
|
def decorator(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if g.user is None:
|
||||||
|
return redirect(url_for('login', next=request.url))
|
||||||
|
|
||||||
|
# Admin and System Admin always have access
|
||||||
|
if g.user.role == Role.ADMIN or g.user.role == Role.SYSTEM_ADMIN:
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
# Define role hierarchy
|
||||||
|
role_hierarchy = {
|
||||||
|
Role.TEAM_MEMBER: 1,
|
||||||
|
Role.TEAM_LEADER: 2,
|
||||||
|
Role.SUPERVISOR: 3,
|
||||||
|
Role.ADMIN: 4,
|
||||||
|
Role.SYSTEM_ADMIN: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
user_role_value = role_hierarchy.get(g.user.role, 0)
|
||||||
|
min_role_value = role_hierarchy.get(min_role, 0)
|
||||||
|
|
||||||
|
if user_role_value < min_role_value:
|
||||||
|
flash('You do not have sufficient permissions to access this page.', 'error')
|
||||||
|
return redirect(url_for('home'))
|
||||||
|
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def company_required(f):
|
||||||
|
"""Decorator to ensure user has a valid company association and set company context"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if g.user is None:
|
||||||
|
return redirect(url_for('login', next=request.url))
|
||||||
|
|
||||||
|
# System admins can access without company association
|
||||||
|
if g.user.role == Role.SYSTEM_ADMIN:
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
if g.user.company_id is None:
|
||||||
|
flash('You must be associated with a company to access this page.', 'error')
|
||||||
|
return redirect(url_for('setup_company'))
|
||||||
|
|
||||||
|
# Set company context
|
||||||
|
g.company = Company.query.get(g.user.company_id)
|
||||||
|
if not g.company or not g.company.is_active:
|
||||||
|
flash('Your company is not active. Please contact support.', 'error')
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
"""Decorator to require admin role for routes"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if g.user is None:
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
if g.user.role not in [Role.ADMIN, Role.SYSTEM_ADMIN]:
|
||||||
|
flash('You must be an administrator to access this page.', 'error')
|
||||||
|
return redirect(url_for('home'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
def system_admin_required(f):
|
||||||
|
"""Decorator to require system admin role for routes"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if g.user is None:
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
if g.user.role != Role.SYSTEM_ADMIN:
|
||||||
|
flash('You must be a system administrator to access this page.', 'error')
|
||||||
|
return redirect(url_for('home'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
360
routes/company.py
Normal file
360
routes/company.py
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
"""
|
||||||
|
Company management routes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, g, session
|
||||||
|
from models import db, Company, User, Role, Team, Project, SystemSettings, CompanyWorkConfig, WorkRegion
|
||||||
|
from routes.auth import admin_required, company_required, login_required
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
companies_bp = Blueprint('companies', __name__, url_prefix='/admin/company')
|
||||||
|
|
||||||
|
|
||||||
|
@companies_bp.route('', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
@company_required
|
||||||
|
def admin_company():
|
||||||
|
"""View and manage company settings"""
|
||||||
|
company = g.company
|
||||||
|
|
||||||
|
# Handle form submissions
|
||||||
|
if request.method == 'POST':
|
||||||
|
action = request.form.get('action')
|
||||||
|
|
||||||
|
if action == 'update_company_details':
|
||||||
|
# Handle company details update
|
||||||
|
name = request.form.get('name')
|
||||||
|
description = request.form.get('description', '')
|
||||||
|
max_users = request.form.get('max_users')
|
||||||
|
is_active = 'is_active' in request.form
|
||||||
|
|
||||||
|
# Validate input
|
||||||
|
error = None
|
||||||
|
if not name:
|
||||||
|
error = 'Company name is required'
|
||||||
|
elif name != company.name and Company.query.filter_by(name=name).first():
|
||||||
|
error = 'Company name already exists'
|
||||||
|
|
||||||
|
if max_users:
|
||||||
|
try:
|
||||||
|
max_users = int(max_users)
|
||||||
|
if max_users < 1:
|
||||||
|
error = 'Maximum users must be at least 1'
|
||||||
|
except ValueError:
|
||||||
|
error = 'Maximum users must be a valid number'
|
||||||
|
else:
|
||||||
|
max_users = None
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
company.name = name
|
||||||
|
company.description = description
|
||||||
|
company.max_users = max_users
|
||||||
|
company.is_active = is_active
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Company details updated successfully!', 'success')
|
||||||
|
else:
|
||||||
|
flash(error, 'error')
|
||||||
|
|
||||||
|
return redirect(url_for('companies.admin_company'))
|
||||||
|
|
||||||
|
elif action == 'update_system_settings':
|
||||||
|
# Update registration setting
|
||||||
|
registration_enabled = 'registration_enabled' in request.form
|
||||||
|
reg_setting = SystemSettings.query.filter_by(key='registration_enabled').first()
|
||||||
|
if reg_setting:
|
||||||
|
reg_setting.value = 'true' if registration_enabled else 'false'
|
||||||
|
|
||||||
|
# Update email verification setting
|
||||||
|
email_verification_required = 'email_verification_required' in request.form
|
||||||
|
email_setting = SystemSettings.query.filter_by(key='email_verification_required').first()
|
||||||
|
if email_setting:
|
||||||
|
email_setting.value = 'true' if email_verification_required else 'false'
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash('System settings updated successfully!', 'success')
|
||||||
|
return redirect(url_for('companies.admin_company'))
|
||||||
|
|
||||||
|
elif action == 'update_work_policies':
|
||||||
|
# Get or create company work config
|
||||||
|
work_config = CompanyWorkConfig.query.filter_by(company_id=g.user.company_id).first()
|
||||||
|
if not work_config:
|
||||||
|
# Create default config for the company
|
||||||
|
preset = CompanyWorkConfig.get_regional_preset(WorkRegion.GERMANY)
|
||||||
|
work_config = CompanyWorkConfig(
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
standard_hours_per_day=preset['standard_hours_per_day'],
|
||||||
|
standard_hours_per_week=preset['standard_hours_per_week'],
|
||||||
|
work_region=WorkRegion.GERMANY,
|
||||||
|
overtime_enabled=preset['overtime_enabled'],
|
||||||
|
overtime_rate=preset['overtime_rate'],
|
||||||
|
double_time_enabled=preset['double_time_enabled'],
|
||||||
|
double_time_threshold=preset['double_time_threshold'],
|
||||||
|
double_time_rate=preset['double_time_rate'],
|
||||||
|
require_breaks=preset['require_breaks'],
|
||||||
|
break_duration_minutes=preset['break_duration_minutes'],
|
||||||
|
break_after_hours=preset['break_after_hours'],
|
||||||
|
weekly_overtime_threshold=preset['weekly_overtime_threshold'],
|
||||||
|
weekly_overtime_rate=preset['weekly_overtime_rate']
|
||||||
|
)
|
||||||
|
db.session.add(work_config)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Handle regional preset selection
|
||||||
|
if request.form.get('apply_preset'):
|
||||||
|
region_code = request.form.get('region_preset')
|
||||||
|
if region_code:
|
||||||
|
region = WorkRegion(region_code)
|
||||||
|
preset = CompanyWorkConfig.get_regional_preset(region)
|
||||||
|
|
||||||
|
work_config.standard_hours_per_day = preset['standard_hours_per_day']
|
||||||
|
work_config.standard_hours_per_week = preset['standard_hours_per_week']
|
||||||
|
work_config.work_region = region
|
||||||
|
work_config.overtime_enabled = preset['overtime_enabled']
|
||||||
|
work_config.overtime_rate = preset['overtime_rate']
|
||||||
|
work_config.double_time_enabled = preset['double_time_enabled']
|
||||||
|
work_config.double_time_threshold = preset['double_time_threshold']
|
||||||
|
work_config.double_time_rate = preset['double_time_rate']
|
||||||
|
work_config.require_breaks = preset['require_breaks']
|
||||||
|
work_config.break_duration_minutes = preset['break_duration_minutes']
|
||||||
|
work_config.break_after_hours = preset['break_after_hours']
|
||||||
|
work_config.weekly_overtime_threshold = preset['weekly_overtime_threshold']
|
||||||
|
work_config.weekly_overtime_rate = preset['weekly_overtime_rate']
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Applied {preset["region_name"]} work policy preset', 'success')
|
||||||
|
else:
|
||||||
|
# Handle manual configuration update
|
||||||
|
work_config.standard_hours_per_day = float(request.form.get('standard_hours_per_day', 8.0))
|
||||||
|
work_config.standard_hours_per_week = float(request.form.get('standard_hours_per_week', 40.0))
|
||||||
|
work_config.overtime_enabled = request.form.get('overtime_enabled') == 'on'
|
||||||
|
work_config.overtime_rate = float(request.form.get('overtime_rate', 1.5))
|
||||||
|
work_config.double_time_enabled = request.form.get('double_time_enabled') == 'on'
|
||||||
|
work_config.double_time_threshold = float(request.form.get('double_time_threshold', 12.0))
|
||||||
|
work_config.double_time_rate = float(request.form.get('double_time_rate', 2.0))
|
||||||
|
work_config.require_breaks = request.form.get('require_breaks') == 'on'
|
||||||
|
work_config.break_duration_minutes = int(request.form.get('break_duration_minutes', 30))
|
||||||
|
work_config.break_after_hours = float(request.form.get('break_after_hours', 6.0))
|
||||||
|
work_config.weekly_overtime_threshold = float(request.form.get('weekly_overtime_threshold', 40.0))
|
||||||
|
work_config.weekly_overtime_rate = float(request.form.get('weekly_overtime_rate', 1.5))
|
||||||
|
work_config.work_region = WorkRegion.OTHER
|
||||||
|
# region_name removed - using work_region enum value instead
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash('Work policies updated successfully!', 'success')
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
flash('Please enter valid numbers for all fields', 'error')
|
||||||
|
|
||||||
|
return redirect(url_for('companies.admin_company'))
|
||||||
|
|
||||||
|
# Get company statistics
|
||||||
|
stats = {
|
||||||
|
'total_users': User.query.filter_by(company_id=company.id).count(),
|
||||||
|
'total_teams': Team.query.filter_by(company_id=company.id).count(),
|
||||||
|
'total_projects': Project.query.filter_by(company_id=company.id).count(),
|
||||||
|
'active_projects': Project.query.filter_by(company_id=company.id, is_active=True).count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get current system settings
|
||||||
|
settings = {}
|
||||||
|
for setting in SystemSettings.query.all():
|
||||||
|
if setting.key == 'registration_enabled':
|
||||||
|
settings['registration_enabled'] = setting.value == 'true'
|
||||||
|
elif setting.key == 'email_verification_required':
|
||||||
|
settings['email_verification_required'] = setting.value == 'true'
|
||||||
|
|
||||||
|
# Get or create company work config
|
||||||
|
work_config = CompanyWorkConfig.query.filter_by(company_id=g.user.company_id).first()
|
||||||
|
if not work_config:
|
||||||
|
# Create default config for the company
|
||||||
|
preset = CompanyWorkConfig.get_regional_preset(WorkRegion.GERMANY)
|
||||||
|
work_config = CompanyWorkConfig(
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
standard_hours_per_day=preset['standard_hours_per_day'],
|
||||||
|
standard_hours_per_week=preset['standard_hours_per_week'],
|
||||||
|
work_region=WorkRegion.GERMANY,
|
||||||
|
overtime_enabled=preset['overtime_enabled'],
|
||||||
|
overtime_rate=preset['overtime_rate'],
|
||||||
|
double_time_enabled=preset['double_time_enabled'],
|
||||||
|
double_time_threshold=preset['double_time_threshold'],
|
||||||
|
double_time_rate=preset['double_time_rate'],
|
||||||
|
require_breaks=preset['require_breaks'],
|
||||||
|
break_duration_minutes=preset['break_duration_minutes'],
|
||||||
|
break_after_hours=preset['break_after_hours'],
|
||||||
|
weekly_overtime_threshold=preset['weekly_overtime_threshold'],
|
||||||
|
weekly_overtime_rate=preset['weekly_overtime_rate']
|
||||||
|
)
|
||||||
|
db.session.add(work_config)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Get available regional presets
|
||||||
|
regional_presets = []
|
||||||
|
for region in WorkRegion:
|
||||||
|
preset = CompanyWorkConfig.get_regional_preset(region)
|
||||||
|
regional_presets.append({
|
||||||
|
'code': region.value,
|
||||||
|
'name': preset['region_name'],
|
||||||
|
'description': f"{preset['standard_hours_per_day']}h/day, {preset['break_duration_minutes']}min break after {preset['break_after_hours']}h"
|
||||||
|
})
|
||||||
|
|
||||||
|
return render_template('admin_company.html',
|
||||||
|
title='Company Management',
|
||||||
|
company=company,
|
||||||
|
stats=stats,
|
||||||
|
settings=settings,
|
||||||
|
work_config=work_config,
|
||||||
|
regional_presets=regional_presets,
|
||||||
|
WorkRegion=WorkRegion)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@companies_bp.route('/users')
|
||||||
|
@admin_required
|
||||||
|
@company_required
|
||||||
|
def company_users():
|
||||||
|
"""List all users in the company with detailed information"""
|
||||||
|
users = User.query.filter_by(company_id=g.company.id).order_by(User.created_at.desc()).all()
|
||||||
|
|
||||||
|
# Calculate user statistics
|
||||||
|
user_stats = {
|
||||||
|
'total': len(users),
|
||||||
|
'verified': len([u for u in users if u.is_verified]),
|
||||||
|
'unverified': len([u for u in users if not u.is_verified]),
|
||||||
|
'blocked': len([u for u in users if u.is_blocked]),
|
||||||
|
'active': len([u for u in users if not u.is_blocked and u.is_verified]),
|
||||||
|
'admins': len([u for u in users if u.role == Role.ADMIN]),
|
||||||
|
'supervisors': len([u for u in users if u.role == Role.SUPERVISOR]),
|
||||||
|
'team_leaders': len([u for u in users if u.role == Role.TEAM_LEADER]),
|
||||||
|
'team_members': len([u for u in users if u.role == Role.TEAM_MEMBER]),
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template('company_users.html',
|
||||||
|
title='Company Users',
|
||||||
|
company=g.company,
|
||||||
|
users=users,
|
||||||
|
stats=user_stats)
|
||||||
|
|
||||||
|
|
||||||
|
# Setup company route (separate from company blueprint due to different URL)
|
||||||
|
def setup_company():
|
||||||
|
"""Company setup route for creating new companies with admin users"""
|
||||||
|
existing_companies = Company.query.count()
|
||||||
|
|
||||||
|
# Determine access level
|
||||||
|
is_initial_setup = existing_companies == 0
|
||||||
|
is_system_admin = g.user and g.user.role == Role.SYSTEM_ADMIN
|
||||||
|
is_authorized = is_initial_setup or is_system_admin
|
||||||
|
|
||||||
|
# Check authorization for non-initial setups
|
||||||
|
if not is_initial_setup and not is_system_admin:
|
||||||
|
flash('You do not have permission to create new companies.', 'error')
|
||||||
|
return redirect(url_for('home') if g.user else url_for('login'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
company_name = request.form.get('company_name')
|
||||||
|
company_description = request.form.get('company_description', '')
|
||||||
|
admin_username = request.form.get('admin_username')
|
||||||
|
admin_email = request.form.get('admin_email')
|
||||||
|
admin_password = request.form.get('admin_password')
|
||||||
|
confirm_password = request.form.get('confirm_password')
|
||||||
|
|
||||||
|
# Validate input
|
||||||
|
error = None
|
||||||
|
if not company_name:
|
||||||
|
error = 'Company name is required'
|
||||||
|
elif not admin_username:
|
||||||
|
error = 'Admin username is required'
|
||||||
|
elif not admin_email:
|
||||||
|
error = 'Admin email is required'
|
||||||
|
elif not admin_password:
|
||||||
|
error = 'Admin password is required'
|
||||||
|
elif admin_password != confirm_password:
|
||||||
|
error = 'Passwords do not match'
|
||||||
|
elif len(admin_password) < 6:
|
||||||
|
error = 'Password must be at least 6 characters long'
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
try:
|
||||||
|
# Generate company slug
|
||||||
|
slug = re.sub(r'[^\w\s-]', '', company_name.lower())
|
||||||
|
slug = re.sub(r'[-\s]+', '-', slug).strip('-')
|
||||||
|
|
||||||
|
# Ensure slug uniqueness
|
||||||
|
base_slug = slug
|
||||||
|
counter = 1
|
||||||
|
while Company.query.filter_by(slug=slug).first():
|
||||||
|
slug = f"{base_slug}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
# Create company
|
||||||
|
company = Company(
|
||||||
|
name=company_name,
|
||||||
|
slug=slug,
|
||||||
|
description=company_description,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.session.add(company)
|
||||||
|
db.session.flush() # Get company.id without committing
|
||||||
|
|
||||||
|
# Check if username/email already exists in this company context
|
||||||
|
existing_user_by_username = User.query.filter_by(
|
||||||
|
username=admin_username,
|
||||||
|
company_id=company.id
|
||||||
|
).first()
|
||||||
|
existing_user_by_email = User.query.filter_by(
|
||||||
|
email=admin_email,
|
||||||
|
company_id=company.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_user_by_username:
|
||||||
|
error = 'Username already exists in this company'
|
||||||
|
elif existing_user_by_email:
|
||||||
|
error = 'Email already registered in this company'
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
# Create admin user
|
||||||
|
admin_user = User(
|
||||||
|
username=admin_username,
|
||||||
|
email=admin_email,
|
||||||
|
company_id=company.id,
|
||||||
|
role=Role.ADMIN,
|
||||||
|
is_verified=True # Auto-verify company admin
|
||||||
|
)
|
||||||
|
admin_user.set_password(admin_password)
|
||||||
|
db.session.add(admin_user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if is_initial_setup:
|
||||||
|
# Auto-login the admin user for initial setup
|
||||||
|
session['user_id'] = admin_user.id
|
||||||
|
session['username'] = admin_user.username
|
||||||
|
session['role'] = admin_user.role.value
|
||||||
|
|
||||||
|
flash(f'Company "{company_name}" created successfully! You are now logged in as the administrator.', 'success')
|
||||||
|
return redirect(url_for('home'))
|
||||||
|
else:
|
||||||
|
# For super admin creating additional companies, don't auto-login
|
||||||
|
flash(f'Company "{company_name}" created successfully! Admin user "{admin_username}" has been created with the company code "{slug}".', 'success')
|
||||||
|
return redirect(url_for('companies.admin_company') if g.user else url_for('login'))
|
||||||
|
else:
|
||||||
|
db.session.rollback()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error during company setup: {str(e)}")
|
||||||
|
error = f"An error occurred during setup: {str(e)}"
|
||||||
|
|
||||||
|
if error:
|
||||||
|
flash(error, 'error')
|
||||||
|
|
||||||
|
return render_template('setup_company.html',
|
||||||
|
title='Company Setup',
|
||||||
|
existing_companies=existing_companies,
|
||||||
|
is_initial_setup=is_initial_setup,
|
||||||
|
is_super_admin=is_system_admin)
|
||||||
102
routes/company_api.py
Normal file
102
routes/company_api.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
Company API endpoints
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, g
|
||||||
|
from models import db, Company, User, Role, Team, Project, TimeEntry
|
||||||
|
from routes.auth import system_admin_required
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
company_api_bp = Blueprint('company_api', __name__, url_prefix='/api')
|
||||||
|
|
||||||
|
|
||||||
|
@company_api_bp.route('/system-admin/companies/<int:company_id>/users')
|
||||||
|
@system_admin_required
|
||||||
|
def api_company_users(company_id):
|
||||||
|
"""API: Get users for a specific company (System Admin only)"""
|
||||||
|
company = Company.query.get_or_404(company_id)
|
||||||
|
users = User.query.filter_by(company_id=company.id).order_by(User.username).all()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'company': {
|
||||||
|
'id': company.id,
|
||||||
|
'name': company.name,
|
||||||
|
'is_personal': company.is_personal
|
||||||
|
},
|
||||||
|
'users': [{
|
||||||
|
'id': user.id,
|
||||||
|
'username': user.username,
|
||||||
|
'email': user.email,
|
||||||
|
'role': user.role.value,
|
||||||
|
'is_blocked': user.is_blocked,
|
||||||
|
'is_verified': user.is_verified,
|
||||||
|
'created_at': user.created_at.isoformat(),
|
||||||
|
'team_id': user.team_id
|
||||||
|
} for user in users]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@company_api_bp.route('/system-admin/companies/<int:company_id>/stats')
|
||||||
|
@system_admin_required
|
||||||
|
def api_company_stats(company_id):
|
||||||
|
"""API: Get detailed statistics for a specific company"""
|
||||||
|
company = Company.query.get_or_404(company_id)
|
||||||
|
|
||||||
|
# User counts by role
|
||||||
|
role_counts = {}
|
||||||
|
for role in Role:
|
||||||
|
count = User.query.filter_by(company_id=company.id, role=role).count()
|
||||||
|
if count > 0:
|
||||||
|
role_counts[role.value] = count
|
||||||
|
|
||||||
|
# Team and project counts
|
||||||
|
team_count = Team.query.filter_by(company_id=company.id).count()
|
||||||
|
project_count = Project.query.filter_by(company_id=company.id).count()
|
||||||
|
active_projects = Project.query.filter_by(company_id=company.id, is_active=True).count()
|
||||||
|
|
||||||
|
# Time entries statistics
|
||||||
|
week_ago = datetime.now() - timedelta(days=7)
|
||||||
|
month_ago = datetime.now() - timedelta(days=30)
|
||||||
|
|
||||||
|
weekly_entries = TimeEntry.query.join(User).filter(
|
||||||
|
User.company_id == company.id,
|
||||||
|
TimeEntry.arrival_time >= week_ago
|
||||||
|
).count()
|
||||||
|
|
||||||
|
monthly_entries = TimeEntry.query.join(User).filter(
|
||||||
|
User.company_id == company.id,
|
||||||
|
TimeEntry.arrival_time >= month_ago
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Active sessions
|
||||||
|
active_sessions = TimeEntry.query.join(User).filter(
|
||||||
|
User.company_id == company.id,
|
||||||
|
TimeEntry.departure_time == None,
|
||||||
|
TimeEntry.is_paused == False
|
||||||
|
).count()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'company': {
|
||||||
|
'id': company.id,
|
||||||
|
'name': company.name,
|
||||||
|
'is_personal': company.is_personal,
|
||||||
|
'is_active': company.is_active
|
||||||
|
},
|
||||||
|
'users': {
|
||||||
|
'total': User.query.filter_by(company_id=company.id).count(),
|
||||||
|
'verified': User.query.filter_by(company_id=company.id, is_verified=True).count(),
|
||||||
|
'blocked': User.query.filter_by(company_id=company.id, is_blocked=True).count(),
|
||||||
|
'roles': role_counts
|
||||||
|
},
|
||||||
|
'teams': team_count,
|
||||||
|
'projects': {
|
||||||
|
'total': project_count,
|
||||||
|
'active': active_projects,
|
||||||
|
'inactive': project_count - active_projects
|
||||||
|
},
|
||||||
|
'time_entries': {
|
||||||
|
'weekly': weekly_entries,
|
||||||
|
'monthly': monthly_entries,
|
||||||
|
'active_sessions': active_sessions
|
||||||
|
}
|
||||||
|
})
|
||||||
94
routes/export.py
Normal file
94
routes/export.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
Export routes for TimeTrack application.
|
||||||
|
Handles data export functionality for time entries and analytics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, g
|
||||||
|
from datetime import datetime, time, timedelta
|
||||||
|
from models import db, TimeEntry, Role, Project
|
||||||
|
from data_formatting import prepare_export_data
|
||||||
|
from data_export import export_to_csv, export_to_excel
|
||||||
|
from routes.auth import login_required, company_required
|
||||||
|
|
||||||
|
# Create blueprint
|
||||||
|
export_bp = Blueprint('export', __name__, url_prefix='/export')
|
||||||
|
|
||||||
|
|
||||||
|
@export_bp.route('/')
|
||||||
|
@login_required
|
||||||
|
@company_required
|
||||||
|
def export_page():
|
||||||
|
"""Display the export page."""
|
||||||
|
return render_template('export.html', title='Export Data')
|
||||||
|
|
||||||
|
|
||||||
|
def get_date_range(period, start_date_str=None, end_date_str=None):
|
||||||
|
"""Get start and end date based on period or custom date range."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
if period:
|
||||||
|
if period == 'today':
|
||||||
|
return today, today
|
||||||
|
elif period == 'week':
|
||||||
|
start_date = today - timedelta(days=today.weekday())
|
||||||
|
return start_date, today
|
||||||
|
elif period == 'month':
|
||||||
|
start_date = today.replace(day=1)
|
||||||
|
return start_date, today
|
||||||
|
elif period == 'all':
|
||||||
|
earliest_entry = TimeEntry.query.order_by(TimeEntry.arrival_time).first()
|
||||||
|
start_date = earliest_entry.arrival_time.date() if earliest_entry else today
|
||||||
|
return start_date, today
|
||||||
|
else:
|
||||||
|
# Custom date range
|
||||||
|
try:
|
||||||
|
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
|
||||||
|
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
|
||||||
|
return start_date, end_date
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise ValueError('Invalid date format')
|
||||||
|
|
||||||
|
|
||||||
|
@export_bp.route('/download')
|
||||||
|
@login_required
|
||||||
|
@company_required
|
||||||
|
def download_export():
|
||||||
|
"""Handle export download requests."""
|
||||||
|
export_format = request.args.get('format', 'csv')
|
||||||
|
period = request.args.get('period')
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_date, end_date = get_date_range(
|
||||||
|
period,
|
||||||
|
request.args.get('start_date'),
|
||||||
|
request.args.get('end_date')
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
flash('Invalid date format. Please use YYYY-MM-DD format.')
|
||||||
|
return redirect(url_for('export.export_page'))
|
||||||
|
|
||||||
|
# Query entries within the date range
|
||||||
|
start_datetime = datetime.combine(start_date, time.min)
|
||||||
|
end_datetime = datetime.combine(end_date, time.max)
|
||||||
|
|
||||||
|
entries = TimeEntry.query.filter(
|
||||||
|
TimeEntry.arrival_time >= start_datetime,
|
||||||
|
TimeEntry.arrival_time <= end_datetime
|
||||||
|
).order_by(TimeEntry.arrival_time).all()
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
flash('No entries found for the selected date range.')
|
||||||
|
return redirect(url_for('export.export_page'))
|
||||||
|
|
||||||
|
# Prepare data and filename
|
||||||
|
data = prepare_export_data(entries)
|
||||||
|
filename = f"timetrack_export_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}"
|
||||||
|
|
||||||
|
# Export based on format
|
||||||
|
if export_format == 'csv':
|
||||||
|
return export_to_csv(data, filename)
|
||||||
|
elif export_format == 'excel':
|
||||||
|
return export_to_excel(data, filename)
|
||||||
|
else:
|
||||||
|
flash('Invalid export format.')
|
||||||
|
return redirect(url_for('export.export_page'))
|
||||||
96
routes/export_api.py
Normal file
96
routes/export_api.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""
|
||||||
|
Export API routes for TimeTrack application.
|
||||||
|
Handles API endpoints for data export functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, request, redirect, url_for, flash, g
|
||||||
|
from datetime import datetime
|
||||||
|
from models import Role
|
||||||
|
from routes.auth import login_required, role_required, company_required
|
||||||
|
from data_export import export_analytics_csv, export_analytics_excel
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Create blueprint
|
||||||
|
export_api_bp = Blueprint('export_api', __name__, url_prefix='/api')
|
||||||
|
|
||||||
|
|
||||||
|
def get_filtered_analytics_data(user, mode, start_date=None, end_date=None, project_filter=None):
|
||||||
|
"""Get filtered time entry data for analytics"""
|
||||||
|
from models import TimeEntry, User
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
# Base query
|
||||||
|
query = TimeEntry.query
|
||||||
|
|
||||||
|
# Apply user/team filter
|
||||||
|
if mode == 'personal':
|
||||||
|
query = query.filter(TimeEntry.user_id == user.id)
|
||||||
|
elif mode == 'team' and user.team_id:
|
||||||
|
team_user_ids = [u.id for u in User.query.filter_by(team_id=user.team_id).all()]
|
||||||
|
query = query.filter(TimeEntry.user_id.in_(team_user_ids))
|
||||||
|
|
||||||
|
# Apply date filters
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(func.date(TimeEntry.arrival_time) >= start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(func.date(TimeEntry.arrival_time) <= end_date)
|
||||||
|
|
||||||
|
# Apply project filter
|
||||||
|
if project_filter:
|
||||||
|
if project_filter == 'none':
|
||||||
|
query = query.filter(TimeEntry.project_id.is_(None))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
project_id = int(project_filter)
|
||||||
|
query = query.filter(TimeEntry.project_id == project_id)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return query.order_by(TimeEntry.arrival_time.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
@export_api_bp.route('/analytics/export')
|
||||||
|
@login_required
|
||||||
|
@company_required
|
||||||
|
def analytics_export():
|
||||||
|
"""Export analytics data in various formats"""
|
||||||
|
export_format = request.args.get('format', 'csv')
|
||||||
|
view_type = request.args.get('view', 'table')
|
||||||
|
mode = request.args.get('mode', 'personal')
|
||||||
|
start_date = request.args.get('start_date')
|
||||||
|
end_date = request.args.get('end_date')
|
||||||
|
project_filter = request.args.get('project_id')
|
||||||
|
|
||||||
|
# Validate permissions
|
||||||
|
if mode == 'team':
|
||||||
|
if not g.user.team_id:
|
||||||
|
flash('No team assigned', 'error')
|
||||||
|
return redirect(url_for('analytics'))
|
||||||
|
if g.user.role not in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN]:
|
||||||
|
flash('Insufficient permissions', 'error')
|
||||||
|
return redirect(url_for('analytics'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse dates
|
||||||
|
if start_date:
|
||||||
|
start_date = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||||
|
if end_date:
|
||||||
|
end_date = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||||
|
|
||||||
|
# Get data
|
||||||
|
data = get_filtered_analytics_data(g.user, mode, start_date, end_date, project_filter)
|
||||||
|
|
||||||
|
if export_format == 'csv':
|
||||||
|
return export_analytics_csv(data, view_type, mode)
|
||||||
|
elif export_format == 'excel':
|
||||||
|
return export_analytics_excel(data, view_type, mode)
|
||||||
|
else:
|
||||||
|
flash('Invalid export format', 'error')
|
||||||
|
return redirect(url_for('analytics'))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in analytics export: {str(e)}")
|
||||||
|
flash('Error generating export', 'error')
|
||||||
|
return redirect(url_for('analytics'))
|
||||||
217
routes/invitations.py
Normal file
217
routes/invitations.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
"""
|
||||||
|
Company invitation routes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, g, jsonify
|
||||||
|
from models import db, CompanyInvitation, User, Role
|
||||||
|
from routes.auth import login_required, admin_required
|
||||||
|
from flask_mail import Message
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
invitations_bp = Blueprint('invitations', __name__, url_prefix='/invitations')
|
||||||
|
|
||||||
|
|
||||||
|
@invitations_bp.route('/')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def list_invitations():
|
||||||
|
"""List all invitations for the company"""
|
||||||
|
invitations = CompanyInvitation.query.filter_by(
|
||||||
|
company_id=g.user.company_id
|
||||||
|
).order_by(CompanyInvitation.created_at.desc()).all()
|
||||||
|
|
||||||
|
# Separate into pending and accepted
|
||||||
|
pending_invitations = [inv for inv in invitations if inv.is_valid()]
|
||||||
|
accepted_invitations = [inv for inv in invitations if inv.accepted]
|
||||||
|
expired_invitations = [inv for inv in invitations if not inv.accepted and inv.is_expired()]
|
||||||
|
|
||||||
|
return render_template('invitations/list.html',
|
||||||
|
pending_invitations=pending_invitations,
|
||||||
|
accepted_invitations=accepted_invitations,
|
||||||
|
expired_invitations=expired_invitations,
|
||||||
|
title='Manage Invitations')
|
||||||
|
|
||||||
|
|
||||||
|
@invitations_bp.route('/send', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def send_invitation():
|
||||||
|
"""Send a new invitation"""
|
||||||
|
if request.method == 'POST':
|
||||||
|
email = request.form.get('email', '').strip()
|
||||||
|
role = request.form.get('role', 'Team Member')
|
||||||
|
custom_message = request.form.get('custom_message', '').strip()
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
flash('Email address is required', 'error')
|
||||||
|
return redirect(url_for('invitations.send_invitation'))
|
||||||
|
|
||||||
|
# Check if user already exists in the company
|
||||||
|
existing_user = User.query.filter_by(
|
||||||
|
email=email,
|
||||||
|
company_id=g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_user:
|
||||||
|
flash(f'A user with email {email} already exists in your company', 'error')
|
||||||
|
return redirect(url_for('invitations.send_invitation'))
|
||||||
|
|
||||||
|
# Check for pending invitations
|
||||||
|
pending_invitation = CompanyInvitation.query.filter_by(
|
||||||
|
email=email,
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
accepted=False
|
||||||
|
).filter(CompanyInvitation.expires_at > datetime.now()).first()
|
||||||
|
|
||||||
|
if pending_invitation:
|
||||||
|
flash(f'An invitation has already been sent to {email} and is still pending', 'warning')
|
||||||
|
return redirect(url_for('invitations.list_invitations'))
|
||||||
|
|
||||||
|
# Create new invitation
|
||||||
|
invitation = CompanyInvitation(
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
email=email,
|
||||||
|
role=role,
|
||||||
|
invited_by_id=g.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(invitation)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Send invitation email
|
||||||
|
try:
|
||||||
|
from app import mail
|
||||||
|
|
||||||
|
# Build invitation URL
|
||||||
|
invitation_url = url_for('register_with_invitation',
|
||||||
|
token=invitation.token,
|
||||||
|
_external=True)
|
||||||
|
|
||||||
|
msg = Message(
|
||||||
|
f'Invitation to join {g.user.company.name} on {g.branding.app_name}',
|
||||||
|
recipients=[email]
|
||||||
|
)
|
||||||
|
|
||||||
|
msg.html = render_template('emails/invitation.html',
|
||||||
|
invitation=invitation,
|
||||||
|
invitation_url=invitation_url,
|
||||||
|
custom_message=custom_message,
|
||||||
|
sender=g.user)
|
||||||
|
|
||||||
|
msg.body = f"""
|
||||||
|
Hello,
|
||||||
|
|
||||||
|
{g.user.username} has invited you to join {g.user.company.name} on {g.branding.app_name}.
|
||||||
|
|
||||||
|
{custom_message if custom_message else ''}
|
||||||
|
|
||||||
|
Click the link below to accept the invitation and create your account:
|
||||||
|
{invitation_url}
|
||||||
|
|
||||||
|
This invitation will expire in 7 days.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
The {g.branding.app_name} Team
|
||||||
|
"""
|
||||||
|
|
||||||
|
mail.send(msg)
|
||||||
|
logger.info(f"Invitation sent to {email} by {g.user.username}")
|
||||||
|
flash(f'Invitation sent successfully to {email}', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending invitation email: {str(e)}")
|
||||||
|
flash('Invitation created but failed to send email. The user can still use the invitation link.', 'warning')
|
||||||
|
|
||||||
|
return redirect(url_for('invitations.list_invitations'))
|
||||||
|
|
||||||
|
# GET request - show the form
|
||||||
|
roles = ['Team Member', 'Team Leader', 'Administrator']
|
||||||
|
return render_template('invitations/send.html', roles=roles, title='Send Invitation')
|
||||||
|
|
||||||
|
|
||||||
|
@invitations_bp.route('/revoke/<int:invitation_id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def revoke_invitation(invitation_id):
|
||||||
|
"""Revoke a pending invitation"""
|
||||||
|
invitation = CompanyInvitation.query.filter_by(
|
||||||
|
id=invitation_id,
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
accepted=False
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not invitation:
|
||||||
|
flash('Invitation not found or already accepted', 'error')
|
||||||
|
return redirect(url_for('invitations.list_invitations'))
|
||||||
|
|
||||||
|
# Instead of deleting, we'll expire it immediately
|
||||||
|
invitation.expires_at = datetime.now()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(f'Invitation to {invitation.email} has been revoked', 'success')
|
||||||
|
return redirect(url_for('invitations.list_invitations'))
|
||||||
|
|
||||||
|
|
||||||
|
@invitations_bp.route('/resend/<int:invitation_id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def resend_invitation(invitation_id):
|
||||||
|
"""Resend an invitation email"""
|
||||||
|
invitation = CompanyInvitation.query.filter_by(
|
||||||
|
id=invitation_id,
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
accepted=False
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not invitation:
|
||||||
|
flash('Invitation not found or already accepted', 'error')
|
||||||
|
return redirect(url_for('invitations.list_invitations'))
|
||||||
|
|
||||||
|
# Extend expiration if needed
|
||||||
|
if invitation.is_expired():
|
||||||
|
invitation.expires_at = datetime.now() + timedelta(days=7)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Resend email
|
||||||
|
try:
|
||||||
|
from app import mail
|
||||||
|
|
||||||
|
invitation_url = url_for('register_with_invitation',
|
||||||
|
token=invitation.token,
|
||||||
|
_external=True)
|
||||||
|
|
||||||
|
msg = Message(
|
||||||
|
f'Reminder: Invitation to join {g.user.company.name}',
|
||||||
|
recipients=[invitation.email]
|
||||||
|
)
|
||||||
|
|
||||||
|
msg.html = render_template('emails/invitation_reminder.html',
|
||||||
|
invitation=invitation,
|
||||||
|
invitation_url=invitation_url,
|
||||||
|
sender=g.user)
|
||||||
|
|
||||||
|
msg.body = f"""
|
||||||
|
Hello,
|
||||||
|
|
||||||
|
This is a reminder that you have been invited to join {g.user.company.name} on {g.branding.app_name}.
|
||||||
|
|
||||||
|
Click the link below to accept the invitation and create your account:
|
||||||
|
{invitation_url}
|
||||||
|
|
||||||
|
This invitation will expire on {invitation.expires_at.strftime('%B %d, %Y')}.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
The {g.branding.app_name} Team
|
||||||
|
"""
|
||||||
|
|
||||||
|
mail.send(msg)
|
||||||
|
flash(f'Invitation resent to {invitation.email}', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error resending invitation email: {str(e)}")
|
||||||
|
flash('Failed to resend invitation email', 'error')
|
||||||
|
|
||||||
|
return redirect(url_for('invitations.list_invitations'))
|
||||||
238
routes/projects.py
Normal file
238
routes/projects.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"""
|
||||||
|
Project Management Routes
|
||||||
|
Handles all project-related views and operations
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, g, abort
|
||||||
|
from datetime import datetime
|
||||||
|
from models import db, Project, Team, ProjectCategory, TimeEntry, Role, Task, User
|
||||||
|
from routes.auth import role_required, company_required, admin_required
|
||||||
|
from utils.validation import FormValidator
|
||||||
|
from utils.repository import ProjectRepository
|
||||||
|
|
||||||
|
projects_bp = Blueprint('projects', __name__, url_prefix='/admin/projects')
|
||||||
|
|
||||||
|
|
||||||
|
@projects_bp.route('')
|
||||||
|
@role_required(Role.SUPERVISOR) # Supervisors and Admins can manage projects
|
||||||
|
@company_required
|
||||||
|
def admin_projects():
|
||||||
|
project_repo = ProjectRepository()
|
||||||
|
projects = project_repo.get_by_company_ordered(g.user.company_id, Project.created_at.desc())
|
||||||
|
categories = ProjectCategory.query.filter_by(company_id=g.user.company_id).order_by(ProjectCategory.name).all()
|
||||||
|
return render_template('admin_projects.html', title='Project Management', projects=projects, categories=categories)
|
||||||
|
|
||||||
|
|
||||||
|
@projects_bp.route('/create', methods=['GET', 'POST'])
|
||||||
|
@role_required(Role.SUPERVISOR)
|
||||||
|
@company_required
|
||||||
|
def create_project():
|
||||||
|
if request.method == 'POST':
|
||||||
|
validator = FormValidator()
|
||||||
|
project_repo = ProjectRepository()
|
||||||
|
|
||||||
|
name = request.form.get('name')
|
||||||
|
description = request.form.get('description')
|
||||||
|
code = request.form.get('code')
|
||||||
|
team_id = request.form.get('team_id') or None
|
||||||
|
category_id = request.form.get('category_id') or None
|
||||||
|
start_date_str = request.form.get('start_date')
|
||||||
|
end_date_str = request.form.get('end_date')
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
validator.validate_required(name, 'Project name')
|
||||||
|
validator.validate_required(code, 'Project code')
|
||||||
|
|
||||||
|
# Validate code uniqueness
|
||||||
|
if validator.is_valid():
|
||||||
|
validator.validate_unique(Project, 'code', code, company_id=g.user.company_id)
|
||||||
|
|
||||||
|
# Parse dates
|
||||||
|
start_date = validator.parse_date(start_date_str, 'Start date')
|
||||||
|
end_date = validator.parse_date(end_date_str, 'End date')
|
||||||
|
|
||||||
|
# Validate date range
|
||||||
|
if start_date and end_date:
|
||||||
|
validator.validate_date_range(start_date, end_date)
|
||||||
|
|
||||||
|
if validator.is_valid():
|
||||||
|
project = project_repo.create(
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
code=code.upper(),
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
team_id=int(team_id) if team_id else None,
|
||||||
|
category_id=int(category_id) if category_id else None,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
created_by_id=g.user.id
|
||||||
|
)
|
||||||
|
project_repo.save()
|
||||||
|
flash(f'Project "{name}" created successfully!', 'success')
|
||||||
|
return redirect(url_for('projects.admin_projects'))
|
||||||
|
else:
|
||||||
|
validator.flash_errors()
|
||||||
|
|
||||||
|
# Get available teams and categories for the form (company-scoped)
|
||||||
|
teams = Team.query.filter_by(company_id=g.user.company_id).order_by(Team.name).all()
|
||||||
|
categories = ProjectCategory.query.filter_by(company_id=g.user.company_id).order_by(ProjectCategory.name).all()
|
||||||
|
return render_template('create_project.html', title='Create Project', teams=teams, categories=categories)
|
||||||
|
|
||||||
|
|
||||||
|
@projects_bp.route('/edit/<int:project_id>', methods=['GET', 'POST'])
|
||||||
|
@role_required(Role.SUPERVISOR)
|
||||||
|
@company_required
|
||||||
|
def edit_project(project_id):
|
||||||
|
project_repo = ProjectRepository()
|
||||||
|
project = project_repo.get_by_id_and_company(project_id, g.user.company_id)
|
||||||
|
|
||||||
|
if not project:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
validator = FormValidator()
|
||||||
|
|
||||||
|
name = request.form.get('name')
|
||||||
|
description = request.form.get('description')
|
||||||
|
code = request.form.get('code')
|
||||||
|
team_id = request.form.get('team_id') or None
|
||||||
|
category_id = request.form.get('category_id') or None
|
||||||
|
is_active = request.form.get('is_active') == 'on'
|
||||||
|
start_date_str = request.form.get('start_date')
|
||||||
|
end_date_str = request.form.get('end_date')
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
validator.validate_required(name, 'Project name')
|
||||||
|
validator.validate_required(code, 'Project code')
|
||||||
|
|
||||||
|
# Validate code uniqueness (exclude current project)
|
||||||
|
if validator.is_valid() and code != project.code:
|
||||||
|
validator.validate_unique(Project, 'code', code, company_id=g.user.company_id)
|
||||||
|
|
||||||
|
# Parse dates
|
||||||
|
start_date = validator.parse_date(start_date_str, 'Start date')
|
||||||
|
end_date = validator.parse_date(end_date_str, 'End date')
|
||||||
|
|
||||||
|
# Validate date range
|
||||||
|
if start_date and end_date:
|
||||||
|
validator.validate_date_range(start_date, end_date)
|
||||||
|
|
||||||
|
if validator.is_valid():
|
||||||
|
project_repo.update(project,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
code=code.upper(),
|
||||||
|
team_id=int(team_id) if team_id else None,
|
||||||
|
category_id=int(category_id) if category_id else None,
|
||||||
|
is_active=is_active,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date
|
||||||
|
)
|
||||||
|
project_repo.save()
|
||||||
|
flash(f'Project "{name}" updated successfully!', 'success')
|
||||||
|
return redirect(url_for('projects.admin_projects'))
|
||||||
|
else:
|
||||||
|
validator.flash_errors()
|
||||||
|
|
||||||
|
# Get available teams and categories for the form (company-scoped)
|
||||||
|
teams = Team.query.filter_by(company_id=g.user.company_id).order_by(Team.name).all()
|
||||||
|
categories = ProjectCategory.query.filter_by(company_id=g.user.company_id).order_by(ProjectCategory.name).all()
|
||||||
|
|
||||||
|
return render_template('edit_project.html', title='Edit Project', project=project, teams=teams, categories=categories)
|
||||||
|
|
||||||
|
|
||||||
|
@projects_bp.route('/delete/<int:project_id>', methods=['POST'])
|
||||||
|
@company_required
|
||||||
|
def delete_project(project_id):
|
||||||
|
# Check if user is admin or system admin
|
||||||
|
if g.user.role not in [Role.ADMIN, Role.SYSTEM_ADMIN]:
|
||||||
|
flash('You do not have permission to delete projects.', 'error')
|
||||||
|
return redirect(url_for('projects.admin_projects'))
|
||||||
|
|
||||||
|
project_repo = ProjectRepository()
|
||||||
|
project = project_repo.get_by_id_and_company(project_id, g.user.company_id)
|
||||||
|
|
||||||
|
if not project:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
project_name = project.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Import models needed for cascading deletions
|
||||||
|
from models import Sprint, SubTask, TaskDependency, Comment
|
||||||
|
|
||||||
|
# Delete all related data in the correct order
|
||||||
|
|
||||||
|
# Delete comments on tasks in this project
|
||||||
|
Comment.query.filter(Comment.task_id.in_(
|
||||||
|
db.session.query(Task.id).filter(Task.project_id == project_id)
|
||||||
|
)).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# Delete subtasks
|
||||||
|
SubTask.query.filter(SubTask.task_id.in_(
|
||||||
|
db.session.query(Task.id).filter(Task.project_id == project_id)
|
||||||
|
)).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# Delete task dependencies
|
||||||
|
TaskDependency.query.filter(
|
||||||
|
TaskDependency.blocked_task_id.in_(
|
||||||
|
db.session.query(Task.id).filter(Task.project_id == project_id)
|
||||||
|
) | TaskDependency.blocking_task_id.in_(
|
||||||
|
db.session.query(Task.id).filter(Task.project_id == project_id)
|
||||||
|
)
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# Delete tasks
|
||||||
|
Task.query.filter_by(project_id=project_id).delete()
|
||||||
|
|
||||||
|
# Delete sprints
|
||||||
|
Sprint.query.filter_by(project_id=project_id).delete()
|
||||||
|
|
||||||
|
# Delete time entries
|
||||||
|
TimeEntry.query.filter_by(project_id=project_id).delete()
|
||||||
|
|
||||||
|
# Finally, delete the project
|
||||||
|
project_repo.delete(project)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(f'Project "{project_name}" and all related data have been permanently deleted.', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error deleting project: {str(e)}', 'error')
|
||||||
|
return redirect(url_for('projects.edit_project', project_id=project_id))
|
||||||
|
|
||||||
|
return redirect(url_for('projects.admin_projects'))
|
||||||
|
|
||||||
|
|
||||||
|
@projects_bp.route('/<int:project_id>/tasks')
|
||||||
|
@role_required(Role.TEAM_MEMBER) # All authenticated users can view tasks
|
||||||
|
@company_required
|
||||||
|
def manage_project_tasks(project_id):
|
||||||
|
project_repo = ProjectRepository()
|
||||||
|
project = project_repo.get_by_id_and_company(project_id, g.user.company_id)
|
||||||
|
|
||||||
|
if not project:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Check if user has access to this project
|
||||||
|
if not project.is_user_allowed(g.user):
|
||||||
|
flash('You do not have access to this project.', 'error')
|
||||||
|
return redirect(url_for('projects.admin_projects'))
|
||||||
|
|
||||||
|
# Get all tasks for this project
|
||||||
|
tasks = Task.query.filter_by(project_id=project_id).order_by(Task.created_at.desc()).all()
|
||||||
|
|
||||||
|
# Get team members for assignment dropdown
|
||||||
|
if project.team_id:
|
||||||
|
# If project is assigned to a specific team, only show team members
|
||||||
|
team_members = User.query.filter_by(team_id=project.team_id, company_id=g.user.company_id).all()
|
||||||
|
else:
|
||||||
|
# If project is available to all teams, show all company users
|
||||||
|
team_members = User.query.filter_by(company_id=g.user.company_id).all()
|
||||||
|
|
||||||
|
return render_template('manage_project_tasks.html',
|
||||||
|
title=f'Tasks - {project.name}',
|
||||||
|
project=project,
|
||||||
|
tasks=tasks,
|
||||||
|
team_members=team_members)
|
||||||
170
routes/projects_api.py
Normal file
170
routes/projects_api.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""
|
||||||
|
Project Management API Routes
|
||||||
|
Handles all project-related API endpoints including categories
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, g
|
||||||
|
from sqlalchemy import or_ as sql_or
|
||||||
|
from models import db, Project, ProjectCategory, Role
|
||||||
|
from routes.auth import role_required, company_required, admin_required
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
projects_api_bp = Blueprint('projects_api', __name__, url_prefix='/api')
|
||||||
|
|
||||||
|
|
||||||
|
# Category Management API Routes
|
||||||
|
@projects_api_bp.route('/admin/categories', methods=['POST'])
|
||||||
|
@role_required(Role.ADMIN)
|
||||||
|
@company_required
|
||||||
|
def create_category():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
name = data.get('name')
|
||||||
|
description = data.get('description', '')
|
||||||
|
color = data.get('color', '#007bff')
|
||||||
|
icon = data.get('icon', '')
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return jsonify({'success': False, 'message': 'Category name is required'})
|
||||||
|
|
||||||
|
# Check if category already exists
|
||||||
|
existing = ProjectCategory.query.filter_by(
|
||||||
|
name=name,
|
||||||
|
company_id=g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return jsonify({'success': False, 'message': 'Category name already exists'})
|
||||||
|
|
||||||
|
category = ProjectCategory(
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
color=color,
|
||||||
|
icon=icon,
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
created_by_id=g.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(category)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Category created successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@projects_api_bp.route('/admin/categories/<int:category_id>', methods=['PUT'])
|
||||||
|
@role_required(Role.ADMIN)
|
||||||
|
@company_required
|
||||||
|
def update_category(category_id):
|
||||||
|
try:
|
||||||
|
category = ProjectCategory.query.filter_by(
|
||||||
|
id=category_id,
|
||||||
|
company_id=g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not category:
|
||||||
|
return jsonify({'success': False, 'message': 'Category not found'})
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
name = data.get('name')
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return jsonify({'success': False, 'message': 'Category name is required'})
|
||||||
|
|
||||||
|
# Check if name conflicts with another category
|
||||||
|
existing = ProjectCategory.query.filter(
|
||||||
|
ProjectCategory.name == name,
|
||||||
|
ProjectCategory.company_id == g.user.company_id,
|
||||||
|
ProjectCategory.id != category_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return jsonify({'success': False, 'message': 'Category name already exists'})
|
||||||
|
|
||||||
|
category.name = name
|
||||||
|
category.description = data.get('description', '')
|
||||||
|
category.color = data.get('color', category.color)
|
||||||
|
category.icon = data.get('icon', '')
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Category updated successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@projects_api_bp.route('/admin/categories/<int:category_id>', methods=['DELETE'])
|
||||||
|
@role_required(Role.ADMIN)
|
||||||
|
@company_required
|
||||||
|
def delete_category(category_id):
|
||||||
|
try:
|
||||||
|
category = ProjectCategory.query.filter_by(
|
||||||
|
id=category_id,
|
||||||
|
company_id=g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not category:
|
||||||
|
return jsonify({'success': False, 'message': 'Category not found'})
|
||||||
|
|
||||||
|
# Unassign projects from this category
|
||||||
|
projects = Project.query.filter_by(category_id=category_id).all()
|
||||||
|
for project in projects:
|
||||||
|
project.category_id = None
|
||||||
|
|
||||||
|
db.session.delete(category)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Category deleted successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@projects_api_bp.route('/search/projects')
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def search_projects():
|
||||||
|
"""Search for projects for smart search auto-completion"""
|
||||||
|
try:
|
||||||
|
query = request.args.get('q', '').strip()
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
return jsonify({'success': True, 'projects': []})
|
||||||
|
|
||||||
|
# Search projects the user has access to
|
||||||
|
projects = Project.query.filter(
|
||||||
|
Project.company_id == g.user.company_id,
|
||||||
|
sql_or(
|
||||||
|
Project.code.ilike(f'%{query}%'),
|
||||||
|
Project.name.ilike(f'%{query}%')
|
||||||
|
)
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
# Filter projects user has access to
|
||||||
|
accessible_projects = [
|
||||||
|
project for project in projects
|
||||||
|
if project.is_user_allowed(g.user)
|
||||||
|
]
|
||||||
|
|
||||||
|
project_list = [
|
||||||
|
{
|
||||||
|
'id': project.id,
|
||||||
|
'code': project.code,
|
||||||
|
'name': project.name
|
||||||
|
}
|
||||||
|
for project in accessible_projects
|
||||||
|
]
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'projects': project_list})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in search_projects: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
47
routes/sprints.py
Normal file
47
routes/sprints.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""
|
||||||
|
Sprint Management Routes
|
||||||
|
Handles all sprint-related views
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, g, redirect, url_for, flash
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from models import db, Role, Project, Sprint
|
||||||
|
from routes.auth import login_required, role_required, company_required
|
||||||
|
|
||||||
|
sprints_bp = Blueprint('sprints', __name__, url_prefix='/sprints')
|
||||||
|
|
||||||
|
|
||||||
|
@sprints_bp.route('')
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def sprint_management():
|
||||||
|
"""Sprint management interface"""
|
||||||
|
|
||||||
|
# Get all projects the user has access to (for sprint assignment)
|
||||||
|
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
|
||||||
|
# Admins and Supervisors can see all company projects
|
||||||
|
available_projects = Project.query.filter_by(
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
is_active=True
|
||||||
|
).order_by(Project.name).all()
|
||||||
|
elif g.user.team_id:
|
||||||
|
# Team members see team projects + unassigned projects
|
||||||
|
available_projects = Project.query.filter(
|
||||||
|
Project.company_id == g.user.company_id,
|
||||||
|
Project.is_active == True,
|
||||||
|
or_(Project.team_id == g.user.team_id, Project.team_id == None)
|
||||||
|
).order_by(Project.name).all()
|
||||||
|
# Filter by actual access permissions
|
||||||
|
available_projects = [p for p in available_projects if p.is_user_allowed(g.user)]
|
||||||
|
else:
|
||||||
|
# Unassigned users see only unassigned projects
|
||||||
|
available_projects = Project.query.filter_by(
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
team_id=None,
|
||||||
|
is_active=True
|
||||||
|
).order_by(Project.name).all()
|
||||||
|
available_projects = [p for p in available_projects if p.is_user_allowed(g.user)]
|
||||||
|
|
||||||
|
return render_template('sprint_management.html',
|
||||||
|
title='Sprint Management',
|
||||||
|
available_projects=available_projects)
|
||||||
224
routes/sprints_api.py
Normal file
224
routes/sprints_api.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
"""
|
||||||
|
Sprint Management API Routes
|
||||||
|
Handles all sprint-related API endpoints
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, g
|
||||||
|
from datetime import datetime
|
||||||
|
from models import db, Role, Project, Sprint, SprintStatus, Task
|
||||||
|
from routes.auth import login_required, role_required, company_required
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
sprints_api_bp = Blueprint('sprints_api', __name__, url_prefix='/api')
|
||||||
|
|
||||||
|
|
||||||
|
@sprints_api_bp.route('/sprints')
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def get_sprints():
|
||||||
|
"""Get all sprints for the user's company"""
|
||||||
|
try:
|
||||||
|
# Base query for sprints in user's company
|
||||||
|
query = Sprint.query.filter(Sprint.company_id == g.user.company_id)
|
||||||
|
|
||||||
|
# Apply access restrictions based on user role and team
|
||||||
|
if g.user.role not in [Role.ADMIN, Role.SUPERVISOR]:
|
||||||
|
# Regular users can only see sprints they have access to
|
||||||
|
accessible_sprint_ids = []
|
||||||
|
sprints = query.all()
|
||||||
|
for sprint in sprints:
|
||||||
|
if sprint.can_user_access(g.user):
|
||||||
|
accessible_sprint_ids.append(sprint.id)
|
||||||
|
|
||||||
|
if accessible_sprint_ids:
|
||||||
|
query = query.filter(Sprint.id.in_(accessible_sprint_ids))
|
||||||
|
else:
|
||||||
|
# No accessible sprints, return empty list
|
||||||
|
return jsonify({'success': True, 'sprints': []})
|
||||||
|
|
||||||
|
sprints = query.order_by(Sprint.created_at.desc()).all()
|
||||||
|
|
||||||
|
sprint_list = []
|
||||||
|
for sprint in sprints:
|
||||||
|
task_summary = sprint.get_task_summary()
|
||||||
|
|
||||||
|
sprint_data = {
|
||||||
|
'id': sprint.id,
|
||||||
|
'name': sprint.name,
|
||||||
|
'description': sprint.description,
|
||||||
|
'status': sprint.status.name,
|
||||||
|
'company_id': sprint.company_id,
|
||||||
|
'project_id': sprint.project_id,
|
||||||
|
'project_name': sprint.project.name if sprint.project else None,
|
||||||
|
'project_code': sprint.project.code if sprint.project else None,
|
||||||
|
'start_date': sprint.start_date.isoformat(),
|
||||||
|
'end_date': sprint.end_date.isoformat(),
|
||||||
|
'goal': sprint.goal,
|
||||||
|
'capacity_hours': sprint.capacity_hours,
|
||||||
|
'created_by_id': sprint.created_by_id,
|
||||||
|
'created_by_name': sprint.created_by.username if sprint.created_by else None,
|
||||||
|
'created_at': sprint.created_at.isoformat(),
|
||||||
|
'is_current': sprint.is_current,
|
||||||
|
'duration_days': sprint.duration_days,
|
||||||
|
'days_remaining': sprint.days_remaining,
|
||||||
|
'progress_percentage': sprint.progress_percentage,
|
||||||
|
'task_summary': task_summary
|
||||||
|
}
|
||||||
|
sprint_list.append(sprint_data)
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'sprints': sprint_list})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in get_sprints: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@sprints_api_bp.route('/sprints', methods=['POST'])
|
||||||
|
@role_required(Role.TEAM_LEADER) # Team leaders and above can create sprints
|
||||||
|
@company_required
|
||||||
|
def create_sprint():
|
||||||
|
"""Create a new sprint"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
name = data.get('name')
|
||||||
|
start_date = data.get('start_date')
|
||||||
|
end_date = data.get('end_date')
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return jsonify({'success': False, 'message': 'Sprint name is required'})
|
||||||
|
if not start_date:
|
||||||
|
return jsonify({'success': False, 'message': 'Start date is required'})
|
||||||
|
if not end_date:
|
||||||
|
return jsonify({'success': False, 'message': 'End date is required'})
|
||||||
|
|
||||||
|
# Parse dates
|
||||||
|
try:
|
||||||
|
start_date = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||||
|
end_date = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'success': False, 'message': 'Invalid date format'})
|
||||||
|
|
||||||
|
if start_date >= end_date:
|
||||||
|
return jsonify({'success': False, 'message': 'End date must be after start date'})
|
||||||
|
|
||||||
|
# Verify project access if project is specified
|
||||||
|
project_id = data.get('project_id')
|
||||||
|
if project_id:
|
||||||
|
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first()
|
||||||
|
if not project or not project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Project not found or access denied'})
|
||||||
|
|
||||||
|
# Create sprint
|
||||||
|
sprint = Sprint(
|
||||||
|
name=name,
|
||||||
|
description=data.get('description', ''),
|
||||||
|
status=SprintStatus[data.get('status', 'PLANNING')],
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
project_id=int(project_id) if project_id else None,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
goal=data.get('goal'),
|
||||||
|
capacity_hours=int(data.get('capacity_hours')) if data.get('capacity_hours') else None,
|
||||||
|
created_by_id=g.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(sprint)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Sprint created successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error creating sprint: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@sprints_api_bp.route('/sprints/<int:sprint_id>', methods=['PUT'])
|
||||||
|
@role_required(Role.TEAM_LEADER)
|
||||||
|
@company_required
|
||||||
|
def update_sprint(sprint_id):
|
||||||
|
"""Update an existing sprint"""
|
||||||
|
try:
|
||||||
|
sprint = Sprint.query.filter_by(id=sprint_id, company_id=g.user.company_id).first()
|
||||||
|
|
||||||
|
if not sprint or not sprint.can_user_access(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Sprint not found or access denied'})
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
# Update sprint fields
|
||||||
|
if 'name' in data:
|
||||||
|
sprint.name = data['name']
|
||||||
|
if 'description' in data:
|
||||||
|
sprint.description = data['description']
|
||||||
|
if 'status' in data:
|
||||||
|
sprint.status = SprintStatus[data['status']]
|
||||||
|
if 'goal' in data:
|
||||||
|
sprint.goal = data['goal']
|
||||||
|
if 'capacity_hours' in data:
|
||||||
|
sprint.capacity_hours = int(data['capacity_hours']) if data['capacity_hours'] else None
|
||||||
|
if 'project_id' in data:
|
||||||
|
project_id = data['project_id']
|
||||||
|
if project_id:
|
||||||
|
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first()
|
||||||
|
if not project or not project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Project not found or access denied'})
|
||||||
|
sprint.project_id = int(project_id)
|
||||||
|
else:
|
||||||
|
sprint.project_id = None
|
||||||
|
|
||||||
|
# Update dates if provided
|
||||||
|
if 'start_date' in data:
|
||||||
|
try:
|
||||||
|
sprint.start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'success': False, 'message': 'Invalid start date format'})
|
||||||
|
|
||||||
|
if 'end_date' in data:
|
||||||
|
try:
|
||||||
|
sprint.end_date = datetime.strptime(data['end_date'], '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'success': False, 'message': 'Invalid end date format'})
|
||||||
|
|
||||||
|
# Validate date order
|
||||||
|
if sprint.start_date >= sprint.end_date:
|
||||||
|
return jsonify({'success': False, 'message': 'End date must be after start date'})
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Sprint updated successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error updating sprint: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@sprints_api_bp.route('/sprints/<int:sprint_id>', methods=['DELETE'])
|
||||||
|
@role_required(Role.TEAM_LEADER)
|
||||||
|
@company_required
|
||||||
|
def delete_sprint(sprint_id):
|
||||||
|
"""Delete a sprint and remove it from all associated tasks"""
|
||||||
|
try:
|
||||||
|
sprint = Sprint.query.filter_by(id=sprint_id, company_id=g.user.company_id).first()
|
||||||
|
|
||||||
|
if not sprint or not sprint.can_user_access(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Sprint not found or access denied'})
|
||||||
|
|
||||||
|
# Remove sprint assignment from all tasks
|
||||||
|
Task.query.filter_by(sprint_id=sprint_id).update({'sprint_id': None})
|
||||||
|
|
||||||
|
# Delete the sprint
|
||||||
|
db.session.delete(sprint)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Sprint deleted successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error deleting sprint: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
511
routes/system_admin.py
Normal file
511
routes/system_admin.py
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
"""
|
||||||
|
System Administrator routes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, g, current_app
|
||||||
|
from models import (db, Company, User, Role, Team, Project, TimeEntry, SystemSettings,
|
||||||
|
SystemEvent, BrandingSettings, Task, SubTask, TaskDependency, Sprint,
|
||||||
|
Comment, UserPreferences, UserDashboard, WorkConfig, CompanySettings,
|
||||||
|
CompanyWorkConfig, ProjectCategory)
|
||||||
|
from routes.auth import system_admin_required
|
||||||
|
from flask import session
|
||||||
|
from sqlalchemy import func
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
system_admin_bp = Blueprint('system_admin', __name__, url_prefix='/system-admin')
|
||||||
|
|
||||||
|
|
||||||
|
@system_admin_bp.route('/dashboard')
|
||||||
|
@system_admin_required
|
||||||
|
def system_admin_dashboard():
|
||||||
|
"""System Administrator Dashboard - view all data across companies"""
|
||||||
|
|
||||||
|
# Global statistics
|
||||||
|
total_companies = Company.query.count()
|
||||||
|
total_users = User.query.count()
|
||||||
|
total_teams = Team.query.count()
|
||||||
|
total_projects = Project.query.count()
|
||||||
|
total_time_entries = TimeEntry.query.count()
|
||||||
|
|
||||||
|
# System admin count
|
||||||
|
system_admins = User.query.filter_by(role=Role.SYSTEM_ADMIN).count()
|
||||||
|
regular_admins = User.query.filter_by(role=Role.ADMIN).count()
|
||||||
|
|
||||||
|
# Recent activity (last 7 days)
|
||||||
|
week_ago = datetime.now() - timedelta(days=7)
|
||||||
|
|
||||||
|
recent_users = User.query.filter(User.created_at >= week_ago).count()
|
||||||
|
recent_companies = Company.query.filter(Company.created_at >= week_ago).count()
|
||||||
|
recent_time_entries = TimeEntry.query.filter(TimeEntry.arrival_time >= week_ago).count()
|
||||||
|
|
||||||
|
# Top companies by user count
|
||||||
|
top_companies = db.session.query(
|
||||||
|
Company.name,
|
||||||
|
Company.id,
|
||||||
|
db.func.count(User.id).label('user_count')
|
||||||
|
).join(User).group_by(Company.id).order_by(db.func.count(User.id).desc()).limit(5).all()
|
||||||
|
|
||||||
|
# Recent companies
|
||||||
|
recent_companies_list = Company.query.order_by(Company.created_at.desc()).limit(5).all()
|
||||||
|
|
||||||
|
# System health checks
|
||||||
|
orphaned_users = User.query.filter_by(company_id=None).count()
|
||||||
|
orphaned_time_entries = TimeEntry.query.filter_by(user_id=None).count()
|
||||||
|
blocked_users = User.query.filter_by(is_blocked=True).count()
|
||||||
|
|
||||||
|
return render_template('system_admin_dashboard.html',
|
||||||
|
title='System Administrator Dashboard',
|
||||||
|
total_companies=total_companies,
|
||||||
|
total_users=total_users,
|
||||||
|
total_teams=total_teams,
|
||||||
|
total_projects=total_projects,
|
||||||
|
total_time_entries=total_time_entries,
|
||||||
|
system_admins=system_admins,
|
||||||
|
regular_admins=regular_admins,
|
||||||
|
recent_users=recent_users,
|
||||||
|
recent_companies=recent_companies,
|
||||||
|
recent_time_entries=recent_time_entries,
|
||||||
|
top_companies=top_companies,
|
||||||
|
recent_companies_list=recent_companies_list,
|
||||||
|
orphaned_users=orphaned_users,
|
||||||
|
orphaned_time_entries=orphaned_time_entries,
|
||||||
|
blocked_users=blocked_users)
|
||||||
|
|
||||||
|
|
||||||
|
@system_admin_bp.route('/companies')
|
||||||
|
@system_admin_required
|
||||||
|
def system_admin_companies():
|
||||||
|
"""System admin view of all companies"""
|
||||||
|
# Get filter parameters
|
||||||
|
search_query = request.args.get('search', '')
|
||||||
|
|
||||||
|
# Base query
|
||||||
|
query = Company.query
|
||||||
|
|
||||||
|
# Apply search filter
|
||||||
|
if search_query:
|
||||||
|
query = query.filter(
|
||||||
|
db.or_(
|
||||||
|
Company.name.ilike(f'%{search_query}%'),
|
||||||
|
Company.slug.ilike(f'%{search_query}%')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get all companies
|
||||||
|
companies = query.order_by(Company.created_at.desc()).all()
|
||||||
|
|
||||||
|
# Create a paginated response object
|
||||||
|
from flask_sqlalchemy import Pagination
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = 20
|
||||||
|
|
||||||
|
# Paginate companies
|
||||||
|
companies_paginated = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
|
||||||
|
# Calculate statistics for each company
|
||||||
|
company_stats = {}
|
||||||
|
for company in companies_paginated.items:
|
||||||
|
company_stats[company.id] = {
|
||||||
|
'user_count': User.query.filter_by(company_id=company.id).count(),
|
||||||
|
'admin_count': User.query.filter_by(company_id=company.id, role=Role.ADMIN).count(),
|
||||||
|
'team_count': Team.query.filter_by(company_id=company.id).count(),
|
||||||
|
'project_count': Project.query.filter_by(company_id=company.id).count(),
|
||||||
|
'active_projects': Project.query.filter_by(company_id=company.id, is_active=True).count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template('system_admin_companies.html',
|
||||||
|
title='System Admin - Companies',
|
||||||
|
companies=companies_paginated,
|
||||||
|
company_stats=company_stats,
|
||||||
|
search_query=search_query)
|
||||||
|
|
||||||
|
|
||||||
|
@system_admin_bp.route('/companies/<int:company_id>')
|
||||||
|
@system_admin_required
|
||||||
|
def system_admin_company_detail(company_id):
|
||||||
|
"""System admin detailed view of a specific company"""
|
||||||
|
company = Company.query.get_or_404(company_id)
|
||||||
|
|
||||||
|
# Get recent time entries count
|
||||||
|
week_ago = datetime.now() - timedelta(days=7)
|
||||||
|
recent_time_entries = TimeEntry.query.join(User).filter(
|
||||||
|
User.company_id == company.id,
|
||||||
|
TimeEntry.arrival_time >= week_ago
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Get role distribution
|
||||||
|
role_counts = {}
|
||||||
|
for role in Role:
|
||||||
|
count = User.query.filter_by(company_id=company.id, role=role).count()
|
||||||
|
if count > 0:
|
||||||
|
role_counts[role.value] = count
|
||||||
|
|
||||||
|
# Get users list
|
||||||
|
users = User.query.filter_by(company_id=company.id).order_by(User.created_at.desc()).all()
|
||||||
|
|
||||||
|
# Get teams list
|
||||||
|
teams = Team.query.filter_by(company_id=company.id).all()
|
||||||
|
|
||||||
|
# Get projects list
|
||||||
|
projects = Project.query.filter_by(company_id=company.id).order_by(Project.created_at.desc()).all()
|
||||||
|
|
||||||
|
return render_template('system_admin_company_detail.html',
|
||||||
|
title=f'Company Details - {company.name}',
|
||||||
|
company=company,
|
||||||
|
users=users,
|
||||||
|
teams=teams,
|
||||||
|
projects=projects,
|
||||||
|
recent_time_entries=recent_time_entries,
|
||||||
|
role_counts=role_counts)
|
||||||
|
|
||||||
|
|
||||||
|
@system_admin_bp.route('/companies/<int:company_id>/delete', methods=['POST'])
|
||||||
|
@system_admin_required
|
||||||
|
def delete_company(company_id):
|
||||||
|
"""System Admin: Delete a company and all its data"""
|
||||||
|
company = Company.query.get_or_404(company_id)
|
||||||
|
company_name = company.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delete all related data in the correct order to avoid foreign key constraints
|
||||||
|
|
||||||
|
# Delete comments (must be before tasks)
|
||||||
|
Comment.query.filter(Comment.task_id.in_(
|
||||||
|
db.session.query(Task.id).join(Project).filter(Project.company_id == company_id)
|
||||||
|
)).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# Delete subtasks
|
||||||
|
SubTask.query.filter(SubTask.task_id.in_(
|
||||||
|
db.session.query(Task.id).join(Project).filter(Project.company_id == company_id)
|
||||||
|
)).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# Delete task dependencies
|
||||||
|
TaskDependency.query.filter(
|
||||||
|
TaskDependency.blocked_task_id.in_(
|
||||||
|
db.session.query(Task.id).join(Project).filter(Project.company_id == company_id)
|
||||||
|
) | TaskDependency.blocking_task_id.in_(
|
||||||
|
db.session.query(Task.id).join(Project).filter(Project.company_id == company_id)
|
||||||
|
)
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# Delete tasks
|
||||||
|
Task.query.filter(Task.project_id.in_(
|
||||||
|
db.session.query(Project.id).filter(Project.company_id == company_id)
|
||||||
|
)).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# Delete sprints
|
||||||
|
Sprint.query.filter(Sprint.project_id.in_(
|
||||||
|
db.session.query(Project.id).filter(Project.company_id == company_id)
|
||||||
|
)).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# Delete time entries
|
||||||
|
TimeEntry.query.filter(TimeEntry.user_id.in_(
|
||||||
|
db.session.query(User.id).filter(User.company_id == company_id)
|
||||||
|
)).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# Delete projects
|
||||||
|
Project.query.filter_by(company_id=company_id).delete()
|
||||||
|
|
||||||
|
# Delete teams
|
||||||
|
Team.query.filter_by(company_id=company_id).delete()
|
||||||
|
|
||||||
|
# Delete user preferences, dashboards, and work configs
|
||||||
|
UserPreferences.query.filter(UserPreferences.user_id.in_(
|
||||||
|
db.session.query(User.id).filter(User.company_id == company_id)
|
||||||
|
)).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
UserDashboard.query.filter(UserDashboard.user_id.in_(
|
||||||
|
db.session.query(User.id).filter(User.company_id == company_id)
|
||||||
|
)).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
WorkConfig.query.filter(WorkConfig.user_id.in_(
|
||||||
|
db.session.query(User.id).filter(User.company_id == company_id)
|
||||||
|
)).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# Delete users
|
||||||
|
User.query.filter_by(company_id=company_id).delete()
|
||||||
|
|
||||||
|
# Delete company settings and work config
|
||||||
|
CompanySettings.query.filter_by(company_id=company_id).delete()
|
||||||
|
CompanyWorkConfig.query.filter_by(company_id=company_id).delete()
|
||||||
|
|
||||||
|
# Delete project categories
|
||||||
|
ProjectCategory.query.filter_by(company_id=company_id).delete()
|
||||||
|
|
||||||
|
# Finally, delete the company
|
||||||
|
db.session.delete(company)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(f'Company "{company_name}" and all its data have been permanently deleted.', 'success')
|
||||||
|
logger.info(f"System admin {g.user.username} deleted company {company_name} (ID: {company_id})")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error deleting company {company_id}: {str(e)}")
|
||||||
|
flash(f'Error deleting company: {str(e)}', 'error')
|
||||||
|
return redirect(url_for('system_admin.system_admin_company_detail', company_id=company_id))
|
||||||
|
|
||||||
|
return redirect(url_for('system_admin.system_admin_companies'))
|
||||||
|
|
||||||
|
|
||||||
|
@system_admin_bp.route('/time-entries')
|
||||||
|
@system_admin_required
|
||||||
|
def system_admin_time_entries():
|
||||||
|
"""System Admin: View time entries across all companies"""
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
company_filter = request.args.get('company', '')
|
||||||
|
per_page = 50
|
||||||
|
|
||||||
|
# Build query properly with explicit joins
|
||||||
|
query = db.session.query(
|
||||||
|
TimeEntry,
|
||||||
|
User.username,
|
||||||
|
Company.name.label('company_name'),
|
||||||
|
Project.name.label('project_name')
|
||||||
|
).join(
|
||||||
|
User, TimeEntry.user_id == User.id
|
||||||
|
).join(
|
||||||
|
Company, User.company_id == Company.id
|
||||||
|
).outerjoin(
|
||||||
|
Project, TimeEntry.project_id == Project.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply company filter
|
||||||
|
if company_filter:
|
||||||
|
query = query.filter(Company.id == company_filter)
|
||||||
|
|
||||||
|
# Order by arrival time (newest first)
|
||||||
|
query = query.order_by(TimeEntry.arrival_time.desc())
|
||||||
|
|
||||||
|
# Paginate
|
||||||
|
entries = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
|
||||||
|
# Get companies for filter dropdown
|
||||||
|
companies = Company.query.order_by(Company.name).all()
|
||||||
|
|
||||||
|
# Get today's date for the template
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
return render_template('system_admin_time_entries.html',
|
||||||
|
title='System Admin - Time Entries',
|
||||||
|
entries=entries,
|
||||||
|
companies=companies,
|
||||||
|
current_company=company_filter,
|
||||||
|
today=today)
|
||||||
|
|
||||||
|
|
||||||
|
@system_admin_bp.route('/settings', methods=['GET', 'POST'])
|
||||||
|
@system_admin_required
|
||||||
|
def system_admin_settings():
|
||||||
|
"""System Admin: Global system settings"""
|
||||||
|
if request.method == 'POST':
|
||||||
|
# Update system settings
|
||||||
|
registration_enabled = request.form.get('registration_enabled') == 'on'
|
||||||
|
email_verification = request.form.get('email_verification_required') == 'on'
|
||||||
|
tracking_script_enabled = request.form.get('tracking_script_enabled') == 'on'
|
||||||
|
tracking_script_code = request.form.get('tracking_script_code', '')
|
||||||
|
|
||||||
|
# Update or create settings
|
||||||
|
reg_setting = SystemSettings.query.filter_by(key='registration_enabled').first()
|
||||||
|
if reg_setting:
|
||||||
|
reg_setting.value = 'true' if registration_enabled else 'false'
|
||||||
|
else:
|
||||||
|
reg_setting = SystemSettings(
|
||||||
|
key='registration_enabled',
|
||||||
|
value='true' if registration_enabled else 'false',
|
||||||
|
description='Controls whether new user registration is allowed'
|
||||||
|
)
|
||||||
|
db.session.add(reg_setting)
|
||||||
|
|
||||||
|
email_setting = SystemSettings.query.filter_by(key='email_verification_required').first()
|
||||||
|
if email_setting:
|
||||||
|
email_setting.value = 'true' if email_verification else 'false'
|
||||||
|
else:
|
||||||
|
email_setting = SystemSettings(
|
||||||
|
key='email_verification_required',
|
||||||
|
value='true' if email_verification else 'false',
|
||||||
|
description='Controls whether email verification is required for new accounts'
|
||||||
|
)
|
||||||
|
db.session.add(email_setting)
|
||||||
|
|
||||||
|
tracking_enabled_setting = SystemSettings.query.filter_by(key='tracking_script_enabled').first()
|
||||||
|
if tracking_enabled_setting:
|
||||||
|
tracking_enabled_setting.value = 'true' if tracking_script_enabled else 'false'
|
||||||
|
else:
|
||||||
|
tracking_enabled_setting = SystemSettings(
|
||||||
|
key='tracking_script_enabled',
|
||||||
|
value='true' if tracking_script_enabled else 'false',
|
||||||
|
description='Controls whether custom tracking script is enabled'
|
||||||
|
)
|
||||||
|
db.session.add(tracking_enabled_setting)
|
||||||
|
|
||||||
|
tracking_code_setting = SystemSettings.query.filter_by(key='tracking_script_code').first()
|
||||||
|
if tracking_code_setting:
|
||||||
|
tracking_code_setting.value = tracking_script_code
|
||||||
|
else:
|
||||||
|
tracking_code_setting = SystemSettings(
|
||||||
|
key='tracking_script_code',
|
||||||
|
value=tracking_script_code,
|
||||||
|
description='Custom tracking script code (HTML/JavaScript)'
|
||||||
|
)
|
||||||
|
db.session.add(tracking_code_setting)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash('System settings updated successfully.', 'success')
|
||||||
|
return redirect(url_for('system_admin.system_admin_settings'))
|
||||||
|
|
||||||
|
# Get current settings
|
||||||
|
settings = {}
|
||||||
|
all_settings = SystemSettings.query.all()
|
||||||
|
for setting in all_settings:
|
||||||
|
if setting.key == 'registration_enabled':
|
||||||
|
settings['registration_enabled'] = setting.value == 'true'
|
||||||
|
elif setting.key == 'email_verification_required':
|
||||||
|
settings['email_verification_required'] = setting.value == 'true'
|
||||||
|
elif setting.key == 'tracking_script_enabled':
|
||||||
|
settings['tracking_script_enabled'] = setting.value == 'true'
|
||||||
|
elif setting.key == 'tracking_script_code':
|
||||||
|
settings['tracking_script_code'] = setting.value
|
||||||
|
|
||||||
|
# System statistics
|
||||||
|
total_companies = Company.query.count()
|
||||||
|
total_users = User.query.count()
|
||||||
|
total_system_admins = User.query.filter_by(role=Role.SYSTEM_ADMIN).count()
|
||||||
|
|
||||||
|
return render_template('system_admin_settings.html',
|
||||||
|
title='System Administrator Settings',
|
||||||
|
settings=settings,
|
||||||
|
total_companies=total_companies,
|
||||||
|
total_users=total_users,
|
||||||
|
total_system_admins=total_system_admins)
|
||||||
|
|
||||||
|
|
||||||
|
@system_admin_bp.route('/health')
|
||||||
|
@system_admin_required
|
||||||
|
def system_admin_health():
|
||||||
|
"""System Admin: System health check and event log"""
|
||||||
|
# Get system health summary
|
||||||
|
health_summary = SystemEvent.get_system_health_summary()
|
||||||
|
|
||||||
|
# Get recent events (last 7 days)
|
||||||
|
recent_events = SystemEvent.get_recent_events(days=7, limit=100)
|
||||||
|
|
||||||
|
# Get events by severity for quick stats
|
||||||
|
errors = SystemEvent.get_events_by_severity('error', days=7, limit=20)
|
||||||
|
warnings = SystemEvent.get_events_by_severity('warning', days=7, limit=20)
|
||||||
|
|
||||||
|
# System metrics
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
# Database connection test
|
||||||
|
db_healthy = True
|
||||||
|
db_error = None
|
||||||
|
try:
|
||||||
|
db.session.execute('SELECT 1')
|
||||||
|
except Exception as e:
|
||||||
|
db_healthy = False
|
||||||
|
db_error = str(e)
|
||||||
|
SystemEvent.log_event(
|
||||||
|
'database_check_failed',
|
||||||
|
f'Database health check failed: {str(e)}',
|
||||||
|
'system',
|
||||||
|
'error'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Application uptime (approximate based on first event)
|
||||||
|
first_event = SystemEvent.query.order_by(SystemEvent.timestamp.asc()).first()
|
||||||
|
uptime_start = first_event.timestamp if first_event else now
|
||||||
|
uptime_duration = now - uptime_start
|
||||||
|
|
||||||
|
# Recent activity stats
|
||||||
|
today = now.date()
|
||||||
|
today_events = SystemEvent.query.filter(
|
||||||
|
func.date(SystemEvent.timestamp) == today
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Log the health check
|
||||||
|
SystemEvent.log_event(
|
||||||
|
'system_health_check',
|
||||||
|
f'System health check performed by {session.get("username", "unknown")}',
|
||||||
|
'system',
|
||||||
|
'info',
|
||||||
|
user_id=session.get('user_id'),
|
||||||
|
ip_address=request.remote_addr,
|
||||||
|
user_agent=request.headers.get('User-Agent')
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template('system_admin_health.html',
|
||||||
|
title='System Health Check',
|
||||||
|
health_summary=health_summary,
|
||||||
|
recent_events=recent_events,
|
||||||
|
errors=errors,
|
||||||
|
warnings=warnings,
|
||||||
|
db_healthy=db_healthy,
|
||||||
|
db_error=db_error,
|
||||||
|
uptime_duration=uptime_duration,
|
||||||
|
today_events=today_events)
|
||||||
|
|
||||||
|
|
||||||
|
@system_admin_bp.route('/branding', methods=['GET', 'POST'])
|
||||||
|
@system_admin_required
|
||||||
|
def branding():
|
||||||
|
"""System Admin: Branding settings"""
|
||||||
|
if request.method == 'POST':
|
||||||
|
branding = BrandingSettings.get_current()
|
||||||
|
|
||||||
|
# Handle form data
|
||||||
|
branding.app_name = request.form.get('app_name', g.branding.app_name).strip()
|
||||||
|
branding.logo_alt_text = request.form.get('logo_alt_text', '').strip()
|
||||||
|
branding.primary_color = request.form.get('primary_color', '#007bff').strip()
|
||||||
|
|
||||||
|
# Handle imprint settings
|
||||||
|
branding.imprint_enabled = 'imprint_enabled' in request.form
|
||||||
|
branding.imprint_title = request.form.get('imprint_title', 'Imprint').strip()
|
||||||
|
branding.imprint_content = request.form.get('imprint_content', '').strip()
|
||||||
|
|
||||||
|
branding.updated_by_id = g.user.id
|
||||||
|
|
||||||
|
# Handle logo upload
|
||||||
|
if 'logo_file' in request.files:
|
||||||
|
logo_file = request.files['logo_file']
|
||||||
|
if logo_file and logo_file.filename:
|
||||||
|
# Create uploads directory if it doesn't exist
|
||||||
|
upload_dir = os.path.join(current_app.static_folder, 'uploads', 'branding')
|
||||||
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Save the file with a timestamp to avoid conflicts
|
||||||
|
import time
|
||||||
|
filename = f"logo_{int(time.time())}_{logo_file.filename}"
|
||||||
|
logo_path = os.path.join(upload_dir, filename)
|
||||||
|
logo_file.save(logo_path)
|
||||||
|
branding.logo_filename = filename
|
||||||
|
|
||||||
|
# Handle favicon upload
|
||||||
|
if 'favicon_file' in request.files:
|
||||||
|
favicon_file = request.files['favicon_file']
|
||||||
|
if favicon_file and favicon_file.filename:
|
||||||
|
# Create uploads directory if it doesn't exist
|
||||||
|
upload_dir = os.path.join(current_app.static_folder, 'uploads', 'branding')
|
||||||
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Save the file with a timestamp to avoid conflicts
|
||||||
|
import time
|
||||||
|
filename = f"favicon_{int(time.time())}_{favicon_file.filename}"
|
||||||
|
favicon_path = os.path.join(upload_dir, filename)
|
||||||
|
favicon_file.save(favicon_path)
|
||||||
|
branding.favicon_filename = filename
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash('Branding settings updated successfully.', 'success')
|
||||||
|
return redirect(url_for('system_admin.branding'))
|
||||||
|
|
||||||
|
# Get current branding settings
|
||||||
|
branding = BrandingSettings.get_current()
|
||||||
|
|
||||||
|
return render_template('system_admin_branding.html',
|
||||||
|
title='System Administrator - Branding Settings',
|
||||||
|
branding=branding)
|
||||||
128
routes/tasks.py
Normal file
128
routes/tasks.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""
|
||||||
|
Task Management Routes
|
||||||
|
Handles all task-related views and operations
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, g, redirect, url_for, flash
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from models import db, Role, Project, Task, User
|
||||||
|
from routes.auth import login_required, role_required, company_required
|
||||||
|
|
||||||
|
tasks_bp = Blueprint('tasks', __name__, url_prefix='/tasks')
|
||||||
|
|
||||||
|
|
||||||
|
def get_filtered_tasks_for_burndown(user, mode, start_date=None, end_date=None, project_filter=None):
|
||||||
|
"""Get filtered tasks for burndown chart"""
|
||||||
|
from datetime import datetime, time
|
||||||
|
|
||||||
|
# Base query - get tasks from user's company
|
||||||
|
query = Task.query.join(Project).filter(Project.company_id == user.company_id)
|
||||||
|
|
||||||
|
# Apply user/team filter
|
||||||
|
if mode == 'personal':
|
||||||
|
# For personal mode, get tasks assigned to the user or created by them
|
||||||
|
query = query.filter(
|
||||||
|
(Task.assigned_to_id == user.id) |
|
||||||
|
(Task.created_by_id == user.id)
|
||||||
|
)
|
||||||
|
elif mode == 'team' and user.team_id:
|
||||||
|
# For team mode, get tasks from projects assigned to the team
|
||||||
|
query = query.filter(Project.team_id == user.team_id)
|
||||||
|
|
||||||
|
# Apply project filter
|
||||||
|
if project_filter:
|
||||||
|
if project_filter == 'none':
|
||||||
|
# No project filter for tasks - they must belong to a project
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
project_id = int(project_filter)
|
||||||
|
query = query.filter(Task.project_id == project_id)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Apply date filters - use task creation date and completion date
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(
|
||||||
|
(Task.created_at >= datetime.combine(start_date, time.min)) |
|
||||||
|
(Task.completed_date >= start_date)
|
||||||
|
)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(
|
||||||
|
Task.created_at <= datetime.combine(end_date, time.max)
|
||||||
|
)
|
||||||
|
|
||||||
|
return query.order_by(Task.created_at.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_bp.route('')
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def unified_task_management():
|
||||||
|
"""Unified task management interface"""
|
||||||
|
|
||||||
|
# Get all projects the user has access to (for filtering and task creation)
|
||||||
|
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
|
||||||
|
# Admins and Supervisors can see all company projects
|
||||||
|
available_projects = Project.query.filter_by(
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
is_active=True
|
||||||
|
).order_by(Project.name).all()
|
||||||
|
elif g.user.team_id:
|
||||||
|
# Team members see team projects + unassigned projects
|
||||||
|
available_projects = Project.query.filter(
|
||||||
|
Project.company_id == g.user.company_id,
|
||||||
|
Project.is_active == True,
|
||||||
|
or_(Project.team_id == g.user.team_id, Project.team_id == None)
|
||||||
|
).order_by(Project.name).all()
|
||||||
|
# Filter by actual access permissions
|
||||||
|
available_projects = [p for p in available_projects if p.is_user_allowed(g.user)]
|
||||||
|
else:
|
||||||
|
# Unassigned users see only unassigned projects
|
||||||
|
available_projects = Project.query.filter_by(
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
team_id=None,
|
||||||
|
is_active=True
|
||||||
|
).order_by(Project.name).all()
|
||||||
|
available_projects = [p for p in available_projects if p.is_user_allowed(g.user)]
|
||||||
|
|
||||||
|
# Get team members for task assignment (company-scoped)
|
||||||
|
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
|
||||||
|
# Admins can assign to anyone in the company
|
||||||
|
team_members = User.query.filter_by(
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
is_blocked=False
|
||||||
|
).order_by(User.username).all()
|
||||||
|
elif g.user.team_id:
|
||||||
|
# Team members can assign to team members + supervisors/admins
|
||||||
|
team_members = User.query.filter(
|
||||||
|
User.company_id == g.user.company_id,
|
||||||
|
User.is_blocked == False,
|
||||||
|
or_(
|
||||||
|
User.team_id == g.user.team_id,
|
||||||
|
User.role.in_([Role.ADMIN, Role.SUPERVISOR])
|
||||||
|
)
|
||||||
|
).order_by(User.username).all()
|
||||||
|
else:
|
||||||
|
# Unassigned users can assign to supervisors/admins only
|
||||||
|
team_members = User.query.filter(
|
||||||
|
User.company_id == g.user.company_id,
|
||||||
|
User.is_blocked == False,
|
||||||
|
User.role.in_([Role.ADMIN, Role.SUPERVISOR])
|
||||||
|
).order_by(User.username).all()
|
||||||
|
|
||||||
|
# Convert team members to JSON-serializable format
|
||||||
|
team_members_data = [{
|
||||||
|
'id': member.id,
|
||||||
|
'username': member.username,
|
||||||
|
'email': member.email,
|
||||||
|
'role': member.role.value if member.role else 'Team Member',
|
||||||
|
'avatar_url': member.get_avatar_url(32)
|
||||||
|
} for member in team_members]
|
||||||
|
|
||||||
|
return render_template('unified_task_management.html',
|
||||||
|
title='Task Management',
|
||||||
|
available_projects=available_projects,
|
||||||
|
team_members=team_members_data)
|
||||||
|
|
||||||
|
|
||||||
985
routes/tasks_api.py
Normal file
985
routes/tasks_api.py
Normal file
@@ -0,0 +1,985 @@
|
|||||||
|
"""
|
||||||
|
Task Management API Routes
|
||||||
|
Handles all task-related API endpoints including subtasks and dependencies
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, g
|
||||||
|
from datetime import datetime
|
||||||
|
from models import (db, Role, Project, Task, User, TaskStatus, TaskPriority, SubTask,
|
||||||
|
TaskDependency, Sprint, CompanySettings, Comment, CommentVisibility)
|
||||||
|
from routes.auth import login_required, role_required, company_required
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
tasks_api_bp = Blueprint('tasks_api', __name__, url_prefix='/api')
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_api_bp.route('/tasks', methods=['POST'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def create_task():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
project_id = data.get('project_id')
|
||||||
|
|
||||||
|
# Verify project access
|
||||||
|
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first()
|
||||||
|
if not project or not project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Project not found or access denied'})
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
name = data.get('name')
|
||||||
|
if not name:
|
||||||
|
return jsonify({'success': False, 'message': 'Task name is required'})
|
||||||
|
|
||||||
|
# Parse dates
|
||||||
|
start_date = None
|
||||||
|
due_date = None
|
||||||
|
if data.get('start_date'):
|
||||||
|
start_date = datetime.strptime(data.get('start_date'), '%Y-%m-%d').date()
|
||||||
|
if data.get('due_date'):
|
||||||
|
due_date = datetime.strptime(data.get('due_date'), '%Y-%m-%d').date()
|
||||||
|
|
||||||
|
# Generate task number
|
||||||
|
task_number = Task.generate_task_number(g.user.company_id)
|
||||||
|
|
||||||
|
# Create task
|
||||||
|
task = Task(
|
||||||
|
task_number=task_number,
|
||||||
|
name=name,
|
||||||
|
description=data.get('description', ''),
|
||||||
|
status=TaskStatus[data.get('status', 'TODO')],
|
||||||
|
priority=TaskPriority[data.get('priority', 'MEDIUM')],
|
||||||
|
estimated_hours=float(data.get('estimated_hours')) if data.get('estimated_hours') else None,
|
||||||
|
project_id=project_id,
|
||||||
|
assigned_to_id=int(data.get('assigned_to_id')) if data.get('assigned_to_id') else None,
|
||||||
|
sprint_id=int(data.get('sprint_id')) if data.get('sprint_id') else None,
|
||||||
|
start_date=start_date,
|
||||||
|
due_date=due_date,
|
||||||
|
created_by_id=g.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(task)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Task created successfully',
|
||||||
|
'task': {
|
||||||
|
'id': task.id,
|
||||||
|
'task_number': task.task_number
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_api_bp.route('/tasks/<int:task_id>', methods=['GET'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def get_task(task_id):
|
||||||
|
try:
|
||||||
|
task = Task.query.join(Project).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not task or not task.can_user_access(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Task not found or access denied'})
|
||||||
|
|
||||||
|
task_data = {
|
||||||
|
'id': task.id,
|
||||||
|
'task_number': getattr(task, 'task_number', f'TSK-{task.id:03d}'),
|
||||||
|
'name': task.name,
|
||||||
|
'description': task.description,
|
||||||
|
'status': task.status.name,
|
||||||
|
'priority': task.priority.name,
|
||||||
|
'estimated_hours': task.estimated_hours,
|
||||||
|
'assigned_to_id': task.assigned_to_id,
|
||||||
|
'assigned_to_name': task.assigned_to.username if task.assigned_to else None,
|
||||||
|
'project_id': task.project_id,
|
||||||
|
'project_name': task.project.name if task.project else None,
|
||||||
|
'project_code': task.project.code if task.project else None,
|
||||||
|
'start_date': task.start_date.isoformat() if task.start_date else None,
|
||||||
|
'due_date': task.due_date.isoformat() if task.due_date else None,
|
||||||
|
'completed_date': task.completed_date.isoformat() if task.completed_date else None,
|
||||||
|
'archived_date': task.archived_date.isoformat() if task.archived_date else None,
|
||||||
|
'sprint_id': task.sprint_id,
|
||||||
|
'subtasks': [{
|
||||||
|
'id': subtask.id,
|
||||||
|
'name': subtask.name,
|
||||||
|
'status': subtask.status.name,
|
||||||
|
'priority': subtask.priority.name,
|
||||||
|
'assigned_to_id': subtask.assigned_to_id,
|
||||||
|
'assigned_to_name': subtask.assigned_to.username if subtask.assigned_to else None
|
||||||
|
} for subtask in task.subtasks] if task.subtasks else []
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(task_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_api_bp.route('/tasks/<int:task_id>', methods=['PUT'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def update_task(task_id):
|
||||||
|
try:
|
||||||
|
task = Task.query.join(Project).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not task or not task.can_user_access(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Task not found or access denied'})
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
# Update task fields
|
||||||
|
if 'name' in data:
|
||||||
|
task.name = data['name']
|
||||||
|
if 'description' in data:
|
||||||
|
task.description = data['description']
|
||||||
|
if 'status' in data:
|
||||||
|
task.status = TaskStatus[data['status']]
|
||||||
|
if data['status'] == 'COMPLETED':
|
||||||
|
task.completed_date = datetime.now().date()
|
||||||
|
else:
|
||||||
|
task.completed_date = None
|
||||||
|
if 'priority' in data:
|
||||||
|
task.priority = TaskPriority[data['priority']]
|
||||||
|
if 'estimated_hours' in data:
|
||||||
|
task.estimated_hours = float(data['estimated_hours']) if data['estimated_hours'] else None
|
||||||
|
if 'assigned_to_id' in data:
|
||||||
|
task.assigned_to_id = int(data['assigned_to_id']) if data['assigned_to_id'] else None
|
||||||
|
if 'sprint_id' in data:
|
||||||
|
task.sprint_id = int(data['sprint_id']) if data['sprint_id'] else None
|
||||||
|
if 'start_date' in data:
|
||||||
|
task.start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date() if data['start_date'] else None
|
||||||
|
if 'due_date' in data:
|
||||||
|
task.due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date() if data['due_date'] else None
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Task updated successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_api_bp.route('/tasks/<int:task_id>', methods=['DELETE'])
|
||||||
|
@role_required(Role.TEAM_LEADER) # Only team leaders and above can delete tasks
|
||||||
|
@company_required
|
||||||
|
def delete_task(task_id):
|
||||||
|
try:
|
||||||
|
task = Task.query.join(Project).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not task or not task.can_user_access(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Task not found or access denied'})
|
||||||
|
|
||||||
|
db.session.delete(task)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Task deleted successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_api_bp.route('/tasks/unified')
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def get_unified_tasks():
|
||||||
|
"""Get all tasks for unified task view"""
|
||||||
|
try:
|
||||||
|
# Base query for tasks in user's company
|
||||||
|
query = Task.query.join(Project).filter(Project.company_id == g.user.company_id)
|
||||||
|
|
||||||
|
# Apply access restrictions based on user role and team
|
||||||
|
if g.user.role not in [Role.ADMIN, Role.SUPERVISOR]:
|
||||||
|
# Regular users can only see tasks from projects they have access to
|
||||||
|
accessible_project_ids = []
|
||||||
|
projects = Project.query.filter_by(company_id=g.user.company_id).all()
|
||||||
|
for project in projects:
|
||||||
|
if project.is_user_allowed(g.user):
|
||||||
|
accessible_project_ids.append(project.id)
|
||||||
|
|
||||||
|
if accessible_project_ids:
|
||||||
|
query = query.filter(Task.project_id.in_(accessible_project_ids))
|
||||||
|
else:
|
||||||
|
# No accessible projects, return empty list
|
||||||
|
return jsonify({'success': True, 'tasks': []})
|
||||||
|
|
||||||
|
tasks = query.order_by(Task.created_at.desc()).all()
|
||||||
|
|
||||||
|
task_list = []
|
||||||
|
for task in tasks:
|
||||||
|
# Determine if this is a team task
|
||||||
|
is_team_task = (
|
||||||
|
g.user.team_id and
|
||||||
|
task.project and
|
||||||
|
task.project.team_id == g.user.team_id
|
||||||
|
)
|
||||||
|
|
||||||
|
task_data = {
|
||||||
|
'id': task.id,
|
||||||
|
'task_number': getattr(task, 'task_number', f'TSK-{task.id:03d}'), # Fallback for existing tasks
|
||||||
|
'name': task.name,
|
||||||
|
'description': task.description,
|
||||||
|
'status': task.status.name,
|
||||||
|
'priority': task.priority.name,
|
||||||
|
'estimated_hours': task.estimated_hours,
|
||||||
|
'project_id': task.project_id,
|
||||||
|
'project_name': task.project.name if task.project else None,
|
||||||
|
'project_code': task.project.code if task.project else None,
|
||||||
|
'assigned_to_id': task.assigned_to_id,
|
||||||
|
'assigned_to_name': task.assigned_to.username if task.assigned_to else None,
|
||||||
|
'created_by_id': task.created_by_id,
|
||||||
|
'created_by_name': task.created_by.username if task.created_by else None,
|
||||||
|
'start_date': task.start_date.isoformat() if task.start_date else None,
|
||||||
|
'due_date': task.due_date.isoformat() if task.due_date else None,
|
||||||
|
'completed_date': task.completed_date.isoformat() if task.completed_date else None,
|
||||||
|
'created_at': task.created_at.isoformat(),
|
||||||
|
'is_team_task': is_team_task,
|
||||||
|
'subtask_count': len(task.subtasks) if task.subtasks else 0,
|
||||||
|
'subtasks': [{
|
||||||
|
'id': subtask.id,
|
||||||
|
'name': subtask.name,
|
||||||
|
'status': subtask.status.name,
|
||||||
|
'priority': subtask.priority.name,
|
||||||
|
'assigned_to_id': subtask.assigned_to_id,
|
||||||
|
'assigned_to_name': subtask.assigned_to.username if subtask.assigned_to else None
|
||||||
|
} for subtask in task.subtasks] if task.subtasks else [],
|
||||||
|
'sprint_id': task.sprint_id,
|
||||||
|
'sprint_name': task.sprint.name if task.sprint else None,
|
||||||
|
'is_current_sprint': task.sprint.is_current if task.sprint else False
|
||||||
|
}
|
||||||
|
task_list.append(task_data)
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'tasks': task_list})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in get_unified_tasks: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_api_bp.route('/tasks/<int:task_id>/status', methods=['PUT'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def update_task_status(task_id):
|
||||||
|
"""Update task status"""
|
||||||
|
try:
|
||||||
|
task = Task.query.join(Project).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not task or not task.can_user_access(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Task not found or access denied'})
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
new_status = data.get('status')
|
||||||
|
|
||||||
|
if not new_status:
|
||||||
|
return jsonify({'success': False, 'message': 'Status is required'})
|
||||||
|
|
||||||
|
# Validate status value - convert from enum name to enum object
|
||||||
|
try:
|
||||||
|
task_status = TaskStatus[new_status]
|
||||||
|
except KeyError:
|
||||||
|
return jsonify({'success': False, 'message': 'Invalid status value'})
|
||||||
|
|
||||||
|
# Update task status
|
||||||
|
old_status = task.status
|
||||||
|
task.status = task_status
|
||||||
|
|
||||||
|
# Set completion date if status is DONE
|
||||||
|
if task_status == TaskStatus.DONE:
|
||||||
|
task.completed_date = datetime.now().date()
|
||||||
|
elif old_status == TaskStatus.DONE:
|
||||||
|
# Clear completion date if moving away from done
|
||||||
|
task.completed_date = None
|
||||||
|
|
||||||
|
# Set archived date if status is ARCHIVED
|
||||||
|
if task_status == TaskStatus.ARCHIVED:
|
||||||
|
task.archived_date = datetime.now().date()
|
||||||
|
elif old_status == TaskStatus.ARCHIVED:
|
||||||
|
# Clear archived date if moving away from archived
|
||||||
|
task.archived_date = None
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Task status updated successfully',
|
||||||
|
'old_status': old_status.name,
|
||||||
|
'new_status': task_status.name
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error updating task status: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
# Task Dependencies APIs
|
||||||
|
@tasks_api_bp.route('/tasks/<int:task_id>/dependencies')
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def get_task_dependencies(task_id):
|
||||||
|
"""Get dependencies for a specific task"""
|
||||||
|
try:
|
||||||
|
# Get the task and verify ownership through project
|
||||||
|
task = Task.query.join(Project).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
if not task:
|
||||||
|
return jsonify({'success': False, 'message': 'Task not found'})
|
||||||
|
|
||||||
|
# Get blocked by dependencies (tasks that block this one)
|
||||||
|
blocked_by_query = db.session.query(Task).join(
|
||||||
|
TaskDependency, Task.id == TaskDependency.blocking_task_id
|
||||||
|
).filter(TaskDependency.blocked_task_id == task_id)
|
||||||
|
|
||||||
|
# Get blocks dependencies (tasks that this one blocks)
|
||||||
|
blocks_query = db.session.query(Task).join(
|
||||||
|
TaskDependency, Task.id == TaskDependency.blocked_task_id
|
||||||
|
).filter(TaskDependency.blocking_task_id == task_id)
|
||||||
|
|
||||||
|
blocked_by_tasks = blocked_by_query.all()
|
||||||
|
blocks_tasks = blocks_query.all()
|
||||||
|
|
||||||
|
def task_to_dict(t):
|
||||||
|
return {
|
||||||
|
'id': t.id,
|
||||||
|
'name': t.name,
|
||||||
|
'task_number': t.task_number
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'dependencies': {
|
||||||
|
'blocked_by': [task_to_dict(t) for t in blocked_by_tasks],
|
||||||
|
'blocks': [task_to_dict(t) for t in blocks_tasks]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting task dependencies: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_api_bp.route('/tasks/<int:task_id>/dependencies', methods=['POST'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def add_task_dependency(task_id):
|
||||||
|
"""Add a dependency for a task"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
task_number = data.get('task_number')
|
||||||
|
dependency_type = data.get('type') # 'blocked_by' or 'blocks'
|
||||||
|
|
||||||
|
if not task_number or not dependency_type:
|
||||||
|
return jsonify({'success': False, 'message': 'Task number and type are required'})
|
||||||
|
|
||||||
|
# Get the main task
|
||||||
|
task = Task.query.join(Project).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
if not task:
|
||||||
|
return jsonify({'success': False, 'message': 'Task not found'})
|
||||||
|
|
||||||
|
# Find the dependency task by task number
|
||||||
|
dependency_task = Task.query.join(Project).filter(
|
||||||
|
Task.task_number == task_number,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not dependency_task:
|
||||||
|
return jsonify({'success': False, 'message': f'Task {task_number} not found'})
|
||||||
|
|
||||||
|
# Prevent self-dependency
|
||||||
|
if dependency_task.id == task_id:
|
||||||
|
return jsonify({'success': False, 'message': 'A task cannot depend on itself'})
|
||||||
|
|
||||||
|
# Create the dependency based on type
|
||||||
|
if dependency_type == 'blocked_by':
|
||||||
|
# Current task is blocked by the dependency task
|
||||||
|
blocked_task_id = task_id
|
||||||
|
blocking_task_id = dependency_task.id
|
||||||
|
elif dependency_type == 'blocks':
|
||||||
|
# Current task blocks the dependency task
|
||||||
|
blocked_task_id = dependency_task.id
|
||||||
|
blocking_task_id = task_id
|
||||||
|
else:
|
||||||
|
return jsonify({'success': False, 'message': 'Invalid dependency type'})
|
||||||
|
|
||||||
|
# Check if dependency already exists
|
||||||
|
existing_dep = TaskDependency.query.filter_by(
|
||||||
|
blocked_task_id=blocked_task_id,
|
||||||
|
blocking_task_id=blocking_task_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_dep:
|
||||||
|
return jsonify({'success': False, 'message': 'This dependency already exists'})
|
||||||
|
|
||||||
|
# Check for circular dependencies
|
||||||
|
def would_create_cycle(blocked_id, blocking_id):
|
||||||
|
# Use a simple DFS to check if adding this dependency would create a cycle
|
||||||
|
visited = set()
|
||||||
|
|
||||||
|
def dfs(current_blocked_id):
|
||||||
|
if current_blocked_id in visited:
|
||||||
|
return False
|
||||||
|
visited.add(current_blocked_id)
|
||||||
|
|
||||||
|
# If we reach the original blocking task, we have a cycle
|
||||||
|
if current_blocked_id == blocking_id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check all tasks that block the current task
|
||||||
|
dependencies = TaskDependency.query.filter_by(blocked_task_id=current_blocked_id).all()
|
||||||
|
for dep in dependencies:
|
||||||
|
if dfs(dep.blocking_task_id):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
return dfs(blocked_id)
|
||||||
|
|
||||||
|
if would_create_cycle(blocked_task_id, blocking_task_id):
|
||||||
|
return jsonify({'success': False, 'message': 'This dependency would create a circular dependency'})
|
||||||
|
|
||||||
|
# Create the new dependency
|
||||||
|
new_dependency = TaskDependency(
|
||||||
|
blocked_task_id=blocked_task_id,
|
||||||
|
blocking_task_id=blocking_task_id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(new_dependency)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Dependency added successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error adding task dependency: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_api_bp.route('/tasks/<int:task_id>/dependencies/<int:dependency_task_id>', methods=['DELETE'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def remove_task_dependency(task_id, dependency_task_id):
|
||||||
|
"""Remove a dependency for a task"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
dependency_type = data.get('type') # 'blocked_by' or 'blocks'
|
||||||
|
|
||||||
|
if not dependency_type:
|
||||||
|
return jsonify({'success': False, 'message': 'Dependency type is required'})
|
||||||
|
|
||||||
|
# Get the main task
|
||||||
|
task = Task.query.join(Project).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
if not task:
|
||||||
|
return jsonify({'success': False, 'message': 'Task not found'})
|
||||||
|
|
||||||
|
# Determine which dependency to remove based on type
|
||||||
|
if dependency_type == 'blocked_by':
|
||||||
|
# Remove dependency where current task is blocked by dependency_task_id
|
||||||
|
dependency = TaskDependency.query.filter_by(
|
||||||
|
blocked_task_id=task_id,
|
||||||
|
blocking_task_id=dependency_task_id
|
||||||
|
).first()
|
||||||
|
elif dependency_type == 'blocks':
|
||||||
|
# Remove dependency where current task blocks dependency_task_id
|
||||||
|
dependency = TaskDependency.query.filter_by(
|
||||||
|
blocked_task_id=dependency_task_id,
|
||||||
|
blocking_task_id=task_id
|
||||||
|
).first()
|
||||||
|
else:
|
||||||
|
return jsonify({'success': False, 'message': 'Invalid dependency type'})
|
||||||
|
|
||||||
|
if not dependency:
|
||||||
|
return jsonify({'success': False, 'message': 'Dependency not found'})
|
||||||
|
|
||||||
|
db.session.delete(dependency)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Dependency removed successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error removing task dependency: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
# Task Archive/Restore APIs
|
||||||
|
@tasks_api_bp.route('/tasks/<int:task_id>/archive', methods=['POST'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def archive_task(task_id):
|
||||||
|
"""Archive a completed task"""
|
||||||
|
try:
|
||||||
|
# Get the task and verify ownership through project
|
||||||
|
task = Task.query.join(Project).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
if not task:
|
||||||
|
return jsonify({'success': False, 'message': 'Task not found'})
|
||||||
|
|
||||||
|
# Only allow archiving completed tasks
|
||||||
|
if task.status != TaskStatus.COMPLETED:
|
||||||
|
return jsonify({'success': False, 'message': 'Only completed tasks can be archived'})
|
||||||
|
|
||||||
|
# Archive the task
|
||||||
|
task.status = TaskStatus.ARCHIVED
|
||||||
|
task.archived_date = datetime.now().date()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Task archived successfully',
|
||||||
|
'archived_date': task.archived_date.isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error archiving task: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_api_bp.route('/tasks/<int:task_id>/restore', methods=['POST'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def restore_task(task_id):
|
||||||
|
"""Restore an archived task to completed status"""
|
||||||
|
try:
|
||||||
|
# Get the task and verify ownership through project
|
||||||
|
task = Task.query.join(Project).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
if not task:
|
||||||
|
return jsonify({'success': False, 'message': 'Task not found'})
|
||||||
|
|
||||||
|
# Only allow restoring archived tasks
|
||||||
|
if task.status != TaskStatus.ARCHIVED:
|
||||||
|
return jsonify({'success': False, 'message': 'Only archived tasks can be restored'})
|
||||||
|
|
||||||
|
# Restore the task to completed status
|
||||||
|
task.status = TaskStatus.COMPLETED
|
||||||
|
task.archived_date = None
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Task restored successfully'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error restoring task: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
# Subtask API Routes
|
||||||
|
@tasks_api_bp.route('/subtasks', methods=['POST'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def create_subtask():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
task_id = data.get('task_id')
|
||||||
|
|
||||||
|
# Verify task access
|
||||||
|
task = Task.query.join(Project).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not task or not task.can_user_access(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Task not found or access denied'})
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
name = data.get('name')
|
||||||
|
if not name:
|
||||||
|
return jsonify({'success': False, 'message': 'Subtask name is required'})
|
||||||
|
|
||||||
|
# Parse dates
|
||||||
|
start_date = None
|
||||||
|
due_date = None
|
||||||
|
if data.get('start_date'):
|
||||||
|
start_date = datetime.strptime(data.get('start_date'), '%Y-%m-%d').date()
|
||||||
|
if data.get('due_date'):
|
||||||
|
due_date = datetime.strptime(data.get('due_date'), '%Y-%m-%d').date()
|
||||||
|
|
||||||
|
# Create subtask
|
||||||
|
subtask = SubTask(
|
||||||
|
name=name,
|
||||||
|
description=data.get('description', ''),
|
||||||
|
status=TaskStatus[data.get('status', 'TODO')],
|
||||||
|
priority=TaskPriority[data.get('priority', 'MEDIUM')],
|
||||||
|
estimated_hours=float(data.get('estimated_hours')) if data.get('estimated_hours') else None,
|
||||||
|
task_id=task_id,
|
||||||
|
assigned_to_id=int(data.get('assigned_to_id')) if data.get('assigned_to_id') else None,
|
||||||
|
start_date=start_date,
|
||||||
|
due_date=due_date,
|
||||||
|
created_by_id=g.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(subtask)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Subtask created successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_api_bp.route('/subtasks/<int:subtask_id>', methods=['GET'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def get_subtask(subtask_id):
|
||||||
|
try:
|
||||||
|
subtask = SubTask.query.join(Task).join(Project).filter(
|
||||||
|
SubTask.id == subtask_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not subtask or not subtask.can_user_access(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Subtask not found or access denied'})
|
||||||
|
|
||||||
|
subtask_data = {
|
||||||
|
'id': subtask.id,
|
||||||
|
'name': subtask.name,
|
||||||
|
'description': subtask.description,
|
||||||
|
'status': subtask.status.name,
|
||||||
|
'priority': subtask.priority.name,
|
||||||
|
'estimated_hours': subtask.estimated_hours,
|
||||||
|
'assigned_to_id': subtask.assigned_to_id,
|
||||||
|
'start_date': subtask.start_date.isoformat() if subtask.start_date else None,
|
||||||
|
'due_date': subtask.due_date.isoformat() if subtask.due_date else None
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'subtask': subtask_data})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_api_bp.route('/subtasks/<int:subtask_id>', methods=['PUT'])
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
@company_required
|
||||||
|
def update_subtask(subtask_id):
|
||||||
|
try:
|
||||||
|
subtask = SubTask.query.join(Task).join(Project).filter(
|
||||||
|
SubTask.id == subtask_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not subtask or not subtask.can_user_access(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Subtask not found or access denied'})
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
# Update subtask fields
|
||||||
|
if 'name' in data:
|
||||||
|
subtask.name = data['name']
|
||||||
|
if 'description' in data:
|
||||||
|
subtask.description = data['description']
|
||||||
|
if 'status' in data:
|
||||||
|
subtask.status = TaskStatus[data['status']]
|
||||||
|
if data['status'] == 'COMPLETED':
|
||||||
|
subtask.completed_date = datetime.now().date()
|
||||||
|
else:
|
||||||
|
subtask.completed_date = None
|
||||||
|
if 'priority' in data:
|
||||||
|
subtask.priority = TaskPriority[data['priority']]
|
||||||
|
if 'estimated_hours' in data:
|
||||||
|
subtask.estimated_hours = float(data['estimated_hours']) if data['estimated_hours'] else None
|
||||||
|
if 'assigned_to_id' in data:
|
||||||
|
subtask.assigned_to_id = int(data['assigned_to_id']) if data['assigned_to_id'] else None
|
||||||
|
if 'start_date' in data:
|
||||||
|
subtask.start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date() if data['start_date'] else None
|
||||||
|
if 'due_date' in data:
|
||||||
|
subtask.due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date() if data['due_date'] else None
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Subtask updated successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_api_bp.route('/subtasks/<int:subtask_id>', methods=['DELETE'])
|
||||||
|
@role_required(Role.TEAM_LEADER) # Only team leaders and above can delete subtasks
|
||||||
|
@company_required
|
||||||
|
def delete_subtask(subtask_id):
|
||||||
|
try:
|
||||||
|
subtask = SubTask.query.join(Task).join(Project).filter(
|
||||||
|
SubTask.id == subtask_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not subtask or not subtask.can_user_access(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Subtask not found or access denied'})
|
||||||
|
|
||||||
|
db.session.delete(subtask)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Subtask deleted successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
# Comment API Routes
|
||||||
|
@tasks_api_bp.route('/tasks/<int:task_id>/comments', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@company_required
|
||||||
|
def handle_task_comments(task_id):
|
||||||
|
"""Handle GET and POST requests for task comments"""
|
||||||
|
if request.method == 'GET':
|
||||||
|
return get_task_comments(task_id)
|
||||||
|
else: # POST
|
||||||
|
return create_task_comment(task_id)
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_api_bp.route('/comments/<int:comment_id>', methods=['PUT', 'DELETE'])
|
||||||
|
@login_required
|
||||||
|
@company_required
|
||||||
|
def handle_comment(comment_id):
|
||||||
|
"""Handle PUT and DELETE requests for a specific comment"""
|
||||||
|
if request.method == 'DELETE':
|
||||||
|
return delete_comment(comment_id)
|
||||||
|
else: # PUT
|
||||||
|
return update_comment(comment_id)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_comment(comment_id):
|
||||||
|
"""Delete a comment"""
|
||||||
|
try:
|
||||||
|
comment = Comment.query.join(Task).join(Project).filter(
|
||||||
|
Comment.id == comment_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not comment:
|
||||||
|
return jsonify({'success': False, 'message': 'Comment not found'}), 404
|
||||||
|
|
||||||
|
# Check if user can delete this comment
|
||||||
|
if not comment.can_user_delete(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'You do not have permission to delete this comment'}), 403
|
||||||
|
|
||||||
|
# Delete the comment (replies will be cascade deleted)
|
||||||
|
db.session.delete(comment)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Comment deleted successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error deleting comment {comment_id}: {e}")
|
||||||
|
return jsonify({'success': False, 'message': 'Failed to delete comment'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
def update_comment(comment_id):
|
||||||
|
"""Update a comment"""
|
||||||
|
try:
|
||||||
|
comment = Comment.query.join(Task).join(Project).filter(
|
||||||
|
Comment.id == comment_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not comment:
|
||||||
|
return jsonify({'success': False, 'message': 'Comment not found'}), 404
|
||||||
|
|
||||||
|
# Check if user can edit this comment
|
||||||
|
if not comment.can_user_edit(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'You do not have permission to edit this comment'}), 403
|
||||||
|
|
||||||
|
data = request.json
|
||||||
|
new_content = data.get('content', '').strip()
|
||||||
|
|
||||||
|
if not new_content:
|
||||||
|
return jsonify({'success': False, 'message': 'Comment content is required'}), 400
|
||||||
|
|
||||||
|
# Update the comment
|
||||||
|
comment.content = new_content
|
||||||
|
comment.is_edited = True
|
||||||
|
comment.edited_at = datetime.now()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Comment updated successfully',
|
||||||
|
'comment': {
|
||||||
|
'id': comment.id,
|
||||||
|
'content': comment.content,
|
||||||
|
'is_edited': comment.is_edited,
|
||||||
|
'edited_at': comment.edited_at.isoformat()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error updating comment {comment_id}: {e}")
|
||||||
|
return jsonify({'success': False, 'message': 'Failed to update comment'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_comments(task_id):
|
||||||
|
"""Get all comments for a task that the user can view"""
|
||||||
|
try:
|
||||||
|
task = Task.query.join(Project).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not task or not task.can_user_access(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Task not found or access denied'})
|
||||||
|
|
||||||
|
# Get all comments for the task
|
||||||
|
comments = []
|
||||||
|
for comment in task.comments.order_by(Comment.created_at.desc()):
|
||||||
|
if comment.can_user_view(g.user):
|
||||||
|
comment_data = {
|
||||||
|
'id': comment.id,
|
||||||
|
'content': comment.content,
|
||||||
|
'visibility': comment.visibility.value,
|
||||||
|
'is_edited': comment.is_edited,
|
||||||
|
'edited_at': comment.edited_at.isoformat() if comment.edited_at else None,
|
||||||
|
'created_at': comment.created_at.isoformat(),
|
||||||
|
'author': {
|
||||||
|
'id': comment.created_by.id,
|
||||||
|
'username': comment.created_by.username,
|
||||||
|
'avatar_url': comment.created_by.get_avatar_url(40)
|
||||||
|
},
|
||||||
|
'can_edit': comment.can_user_edit(g.user),
|
||||||
|
'can_delete': comment.can_user_delete(g.user),
|
||||||
|
'replies': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add replies if any
|
||||||
|
for reply in comment.replies:
|
||||||
|
if reply.can_user_view(g.user):
|
||||||
|
reply_data = {
|
||||||
|
'id': reply.id,
|
||||||
|
'content': reply.content,
|
||||||
|
'is_edited': reply.is_edited,
|
||||||
|
'edited_at': reply.edited_at.isoformat() if reply.edited_at else None,
|
||||||
|
'created_at': reply.created_at.isoformat(),
|
||||||
|
'author': {
|
||||||
|
'id': reply.created_by.id,
|
||||||
|
'username': reply.created_by.username,
|
||||||
|
'avatar_url': reply.created_by.get_avatar_url(40)
|
||||||
|
},
|
||||||
|
'can_edit': reply.can_user_edit(g.user),
|
||||||
|
'can_delete': reply.can_user_delete(g.user)
|
||||||
|
}
|
||||||
|
comment_data['replies'].append(reply_data)
|
||||||
|
|
||||||
|
comments.append(comment_data)
|
||||||
|
|
||||||
|
# Check if user can use team visibility
|
||||||
|
company_settings = CompanySettings.query.filter_by(company_id=g.user.company_id).first()
|
||||||
|
allow_team_visibility = company_settings.allow_team_visibility_comments if company_settings else True
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'comments': comments,
|
||||||
|
'allow_team_visibility': allow_team_visibility
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
def create_task_comment(task_id):
|
||||||
|
"""Create a new comment on a task"""
|
||||||
|
try:
|
||||||
|
# Get the task and verify access through project
|
||||||
|
task = Task.query.join(Project).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Project.company_id == g.user.company_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
return jsonify({'success': False, 'message': 'Task not found'})
|
||||||
|
|
||||||
|
# Check if user has access to this task's project
|
||||||
|
if not task.project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'success': False, 'message': 'Access denied'})
|
||||||
|
|
||||||
|
data = request.json
|
||||||
|
content = data.get('content', '').strip()
|
||||||
|
visibility = data.get('visibility', CommentVisibility.COMPANY.value)
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
return jsonify({'success': False, 'message': 'Comment content is required'})
|
||||||
|
|
||||||
|
# Validate visibility - handle case conversion
|
||||||
|
try:
|
||||||
|
# Convert from frontend format (TEAM/COMPANY) to enum format (Team/Company)
|
||||||
|
if visibility == 'TEAM':
|
||||||
|
visibility_enum = CommentVisibility.TEAM
|
||||||
|
elif visibility == 'COMPANY':
|
||||||
|
visibility_enum = CommentVisibility.COMPANY
|
||||||
|
else:
|
||||||
|
# Try to use the value directly in case it's already in the right format
|
||||||
|
visibility_enum = CommentVisibility(visibility)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'success': False, 'message': f'Invalid visibility option: {visibility}'})
|
||||||
|
|
||||||
|
# Create the comment
|
||||||
|
comment = Comment(
|
||||||
|
content=content,
|
||||||
|
task_id=task_id,
|
||||||
|
created_by_id=g.user.id,
|
||||||
|
visibility=visibility_enum
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(comment)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Comment added successfully',
|
||||||
|
'comment': {
|
||||||
|
'id': comment.id,
|
||||||
|
'content': comment.content,
|
||||||
|
'visibility': comment.visibility.value,
|
||||||
|
'created_at': comment.created_at.isoformat(),
|
||||||
|
'user': {
|
||||||
|
'id': comment.created_by.id,
|
||||||
|
'username': comment.created_by.username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error creating task comment: {str(e)}")
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
191
routes/teams.py
Normal file
191
routes/teams.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
"""
|
||||||
|
Team Management Routes
|
||||||
|
Handles all team-related views and operations
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, g, abort
|
||||||
|
from models import db, Team, User
|
||||||
|
from routes.auth import admin_required, company_required
|
||||||
|
from utils.validation import FormValidator
|
||||||
|
from utils.repository import TeamRepository
|
||||||
|
|
||||||
|
teams_bp = Blueprint('teams', __name__, url_prefix='/admin/teams')
|
||||||
|
|
||||||
|
|
||||||
|
@teams_bp.route('')
|
||||||
|
@admin_required
|
||||||
|
@company_required
|
||||||
|
def admin_teams():
|
||||||
|
team_repo = TeamRepository()
|
||||||
|
teams = team_repo.get_with_member_count(g.user.company_id)
|
||||||
|
return render_template('admin_teams.html', title='Team Management', teams=teams)
|
||||||
|
|
||||||
|
|
||||||
|
@teams_bp.route('/create', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
@company_required
|
||||||
|
def create_team():
|
||||||
|
if request.method == 'POST':
|
||||||
|
validator = FormValidator()
|
||||||
|
team_repo = TeamRepository()
|
||||||
|
|
||||||
|
name = request.form.get('name')
|
||||||
|
description = request.form.get('description')
|
||||||
|
|
||||||
|
# Validate input
|
||||||
|
validator.validate_required(name, 'Team name')
|
||||||
|
|
||||||
|
if validator.is_valid() and team_repo.exists_by_name_in_company(name, g.user.company_id):
|
||||||
|
validator.errors.add('Team name already exists in your company')
|
||||||
|
|
||||||
|
if validator.is_valid():
|
||||||
|
new_team = team_repo.create(
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
company_id=g.user.company_id
|
||||||
|
)
|
||||||
|
team_repo.save()
|
||||||
|
|
||||||
|
flash(f'Team "{name}" created successfully!', 'success')
|
||||||
|
return redirect(url_for('teams.admin_teams'))
|
||||||
|
|
||||||
|
validator.flash_errors()
|
||||||
|
|
||||||
|
return render_template('team_form.html', title='Create Team', team=None)
|
||||||
|
|
||||||
|
|
||||||
|
@teams_bp.route('/edit/<int:team_id>', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
@company_required
|
||||||
|
def edit_team(team_id):
|
||||||
|
team_repo = TeamRepository()
|
||||||
|
team = team_repo.get_by_id_and_company(team_id, g.user.company_id)
|
||||||
|
|
||||||
|
if not team:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
validator = FormValidator()
|
||||||
|
|
||||||
|
name = request.form.get('name')
|
||||||
|
description = request.form.get('description')
|
||||||
|
|
||||||
|
# Validate input
|
||||||
|
validator.validate_required(name, 'Team name')
|
||||||
|
|
||||||
|
if validator.is_valid() and name != team.name:
|
||||||
|
if team_repo.exists_by_name_in_company(name, g.user.company_id):
|
||||||
|
validator.errors.add('Team name already exists in your company')
|
||||||
|
|
||||||
|
if validator.is_valid():
|
||||||
|
team_repo.update(team, name=name, description=description)
|
||||||
|
team_repo.save()
|
||||||
|
|
||||||
|
flash(f'Team "{name}" updated successfully!', 'success')
|
||||||
|
return redirect(url_for('teams.admin_teams'))
|
||||||
|
|
||||||
|
validator.flash_errors()
|
||||||
|
|
||||||
|
return render_template('edit_team.html', title='Edit Team', team=team)
|
||||||
|
|
||||||
|
|
||||||
|
@teams_bp.route('/delete/<int:team_id>', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
@company_required
|
||||||
|
def delete_team(team_id):
|
||||||
|
team_repo = TeamRepository()
|
||||||
|
team = team_repo.get_by_id_and_company(team_id, g.user.company_id)
|
||||||
|
|
||||||
|
if not team:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Check if team has members
|
||||||
|
if team.users:
|
||||||
|
flash('Cannot delete team with members. Remove all members first.', 'error')
|
||||||
|
return redirect(url_for('teams.admin_teams'))
|
||||||
|
|
||||||
|
team_name = team.name
|
||||||
|
team_repo.delete(team)
|
||||||
|
team_repo.save()
|
||||||
|
|
||||||
|
flash(f'Team "{team_name}" deleted successfully!', 'success')
|
||||||
|
return redirect(url_for('teams.admin_teams'))
|
||||||
|
|
||||||
|
|
||||||
|
@teams_bp.route('/<int:team_id>', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
@company_required
|
||||||
|
def manage_team(team_id):
|
||||||
|
team_repo = TeamRepository()
|
||||||
|
team = team_repo.get_by_id_and_company(team_id, g.user.company_id)
|
||||||
|
|
||||||
|
if not team:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
action = request.form.get('action')
|
||||||
|
|
||||||
|
if action == 'update_team':
|
||||||
|
# Update team details
|
||||||
|
validator = FormValidator()
|
||||||
|
name = request.form.get('name')
|
||||||
|
description = request.form.get('description')
|
||||||
|
|
||||||
|
# Validate input
|
||||||
|
validator.validate_required(name, 'Team name')
|
||||||
|
|
||||||
|
if validator.is_valid() and name != team.name:
|
||||||
|
if team_repo.exists_by_name_in_company(name, g.user.company_id):
|
||||||
|
validator.errors.add('Team name already exists in your company')
|
||||||
|
|
||||||
|
if validator.is_valid():
|
||||||
|
team_repo.update(team, name=name, description=description)
|
||||||
|
team_repo.save()
|
||||||
|
flash(f'Team "{name}" updated successfully!', 'success')
|
||||||
|
else:
|
||||||
|
validator.flash_errors()
|
||||||
|
|
||||||
|
elif action == 'add_member':
|
||||||
|
# Add user to team
|
||||||
|
user_id = request.form.get('user_id')
|
||||||
|
if user_id:
|
||||||
|
user = User.query.get(user_id)
|
||||||
|
if user:
|
||||||
|
user.team_id = team.id
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'User {user.username} added to team!', 'success')
|
||||||
|
else:
|
||||||
|
flash('User not found', 'error')
|
||||||
|
else:
|
||||||
|
flash('No user selected', 'error')
|
||||||
|
|
||||||
|
elif action == 'remove_member':
|
||||||
|
# Remove user from team
|
||||||
|
user_id = request.form.get('user_id')
|
||||||
|
if user_id:
|
||||||
|
user = User.query.get(user_id)
|
||||||
|
if user and user.team_id == team.id:
|
||||||
|
user.team_id = None
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'User {user.username} removed from team!', 'success')
|
||||||
|
else:
|
||||||
|
flash('User not found or not in this team', 'error')
|
||||||
|
else:
|
||||||
|
flash('No user selected', 'error')
|
||||||
|
|
||||||
|
# Get team members
|
||||||
|
team_members = User.query.filter_by(team_id=team.id).all()
|
||||||
|
|
||||||
|
# Get users not in this team for the add member form (company-scoped)
|
||||||
|
available_users = User.query.filter(
|
||||||
|
User.company_id == g.user.company_id,
|
||||||
|
(User.team_id != team.id) | (User.team_id == None)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'team_form.html',
|
||||||
|
title=f'Manage Team: {team.name}',
|
||||||
|
team=team,
|
||||||
|
team_members=team_members,
|
||||||
|
available_users=available_users
|
||||||
|
)
|
||||||
124
routes/teams_api.py
Normal file
124
routes/teams_api.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""
|
||||||
|
Team Management API Routes
|
||||||
|
Handles all team-related API endpoints
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, g
|
||||||
|
from datetime import datetime, time, timedelta
|
||||||
|
from models import Team, User, TimeEntry, Role
|
||||||
|
from routes.auth import login_required, role_required, company_required, system_admin_required
|
||||||
|
|
||||||
|
teams_api_bp = Blueprint('teams_api', __name__, url_prefix='/api')
|
||||||
|
|
||||||
|
|
||||||
|
@teams_api_bp.route('/team/hours_data', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
@role_required(Role.TEAM_LEADER) # Only team leaders and above can access
|
||||||
|
@company_required
|
||||||
|
def team_hours_data():
|
||||||
|
# Get the current user's team
|
||||||
|
team = Team.query.get(g.user.team_id)
|
||||||
|
|
||||||
|
if not team:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'You are not assigned to any team.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Get date range from query parameters or use current week as default
|
||||||
|
today = datetime.now().date()
|
||||||
|
start_of_week = today - timedelta(days=today.weekday())
|
||||||
|
end_of_week = start_of_week + timedelta(days=6)
|
||||||
|
|
||||||
|
start_date_str = request.args.get('start_date', start_of_week.strftime('%Y-%m-%d'))
|
||||||
|
end_date_str = request.args.get('end_date', end_of_week.strftime('%Y-%m-%d'))
|
||||||
|
include_self = request.args.get('include_self', 'false') == 'true'
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
|
||||||
|
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Invalid date format.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Get all team members
|
||||||
|
team_members = User.query.filter_by(team_id=team.id).all()
|
||||||
|
|
||||||
|
# Prepare data structure for team members' hours
|
||||||
|
team_data = []
|
||||||
|
|
||||||
|
for member in team_members:
|
||||||
|
# Skip if the member is the current user (team leader) and include_self is False
|
||||||
|
if member.id == g.user.id and not include_self:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get time entries for this member in the date range
|
||||||
|
entries = TimeEntry.query.filter(
|
||||||
|
TimeEntry.user_id == member.id,
|
||||||
|
TimeEntry.arrival_time >= datetime.combine(start_date, time.min),
|
||||||
|
TimeEntry.arrival_time <= datetime.combine(end_date, time.max)
|
||||||
|
).order_by(TimeEntry.arrival_time).all()
|
||||||
|
|
||||||
|
# Calculate daily and total hours
|
||||||
|
daily_hours = {}
|
||||||
|
total_seconds = 0
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if entry.duration: # Only count completed entries
|
||||||
|
entry_date = entry.arrival_time.date()
|
||||||
|
date_str = entry_date.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
if date_str not in daily_hours:
|
||||||
|
daily_hours[date_str] = 0
|
||||||
|
|
||||||
|
daily_hours[date_str] += entry.duration
|
||||||
|
total_seconds += entry.duration
|
||||||
|
|
||||||
|
# Convert seconds to hours for display
|
||||||
|
for date_str in daily_hours:
|
||||||
|
daily_hours[date_str] = round(daily_hours[date_str] / 3600, 2) # Convert to hours
|
||||||
|
|
||||||
|
total_hours = round(total_seconds / 3600, 2) # Convert to hours
|
||||||
|
|
||||||
|
# Format entries for JSON response
|
||||||
|
formatted_entries = []
|
||||||
|
for entry in entries:
|
||||||
|
formatted_entries.append({
|
||||||
|
'id': entry.id,
|
||||||
|
'arrival_time': entry.arrival_time.isoformat(),
|
||||||
|
'departure_time': entry.departure_time.isoformat() if entry.departure_time else None,
|
||||||
|
'duration': entry.duration,
|
||||||
|
'total_break_duration': entry.total_break_duration
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add member data to team data
|
||||||
|
team_data.append({
|
||||||
|
'member_id': member.id,
|
||||||
|
'member_name': member.username,
|
||||||
|
'daily_hours': daily_hours,
|
||||||
|
'total_hours': total_hours,
|
||||||
|
'entries': formatted_entries
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'team_name': team.name,
|
||||||
|
'team_id': team.id,
|
||||||
|
'start_date': start_date_str,
|
||||||
|
'end_date': end_date_str,
|
||||||
|
'team_data': team_data
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@teams_api_bp.route('/companies/<int:company_id>/teams')
|
||||||
|
@system_admin_required
|
||||||
|
def api_company_teams(company_id):
|
||||||
|
"""API: Get teams for a specific company (System Admin only)"""
|
||||||
|
teams = Team.query.filter_by(company_id=company_id).order_by(Team.name).all()
|
||||||
|
return jsonify([{
|
||||||
|
'id': team.id,
|
||||||
|
'name': team.name,
|
||||||
|
'description': team.description
|
||||||
|
} for team in teams])
|
||||||
532
routes/users.py
Normal file
532
routes/users.py
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
"""
|
||||||
|
User management routes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, g, session, abort
|
||||||
|
from models import db, User, Role, Team, TimeEntry, WorkConfig, UserPreferences, Project, Task, SubTask, ProjectCategory, UserDashboard, Comment, Company
|
||||||
|
from routes.auth import admin_required, company_required, login_required, system_admin_required
|
||||||
|
from flask_mail import Message
|
||||||
|
from flask import current_app
|
||||||
|
from utils.validation import FormValidator
|
||||||
|
from utils.repository import UserRepository
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
users_bp = Blueprint('users', __name__, url_prefix='/admin/users')
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_roles():
|
||||||
|
"""Get roles available for user creation/editing based on current user's role"""
|
||||||
|
current_user_role = g.user.role
|
||||||
|
|
||||||
|
if current_user_role == Role.SYSTEM_ADMIN:
|
||||||
|
# System admin can assign any role
|
||||||
|
return [Role.TEAM_MEMBER, Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN, Role.SYSTEM_ADMIN]
|
||||||
|
elif current_user_role == Role.ADMIN:
|
||||||
|
# Admin can assign any role except system admin
|
||||||
|
return [Role.TEAM_MEMBER, Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN]
|
||||||
|
elif current_user_role == Role.SUPERVISOR:
|
||||||
|
# Supervisor can only assign team member and team leader roles
|
||||||
|
return [Role.TEAM_MEMBER, Role.TEAM_LEADER]
|
||||||
|
else:
|
||||||
|
# Others cannot assign roles
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.route('')
|
||||||
|
@admin_required
|
||||||
|
@company_required
|
||||||
|
def admin_users():
|
||||||
|
user_repo = UserRepository()
|
||||||
|
users = user_repo.get_by_company(g.user.company_id)
|
||||||
|
return render_template('admin_users.html', title='User Management', users=users)
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.route('/create', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
@company_required
|
||||||
|
def create_user():
|
||||||
|
if request.method == 'POST':
|
||||||
|
validator = FormValidator()
|
||||||
|
user_repo = UserRepository()
|
||||||
|
|
||||||
|
username = request.form.get('username')
|
||||||
|
email = request.form.get('email')
|
||||||
|
password = request.form.get('password')
|
||||||
|
auto_verify = 'auto_verify' in request.form
|
||||||
|
|
||||||
|
# Get role and team
|
||||||
|
role_name = request.form.get('role')
|
||||||
|
team_id = request.form.get('team_id')
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
validator.validate_required(username, 'Username')
|
||||||
|
validator.validate_required(email, 'Email')
|
||||||
|
validator.validate_required(password, 'Password')
|
||||||
|
|
||||||
|
# Validate uniqueness
|
||||||
|
if validator.is_valid():
|
||||||
|
validator.validate_unique(User, 'username', username, company_id=g.user.company_id)
|
||||||
|
validator.validate_unique(User, 'email', email, company_id=g.user.company_id)
|
||||||
|
|
||||||
|
if validator.is_valid():
|
||||||
|
# Convert role string to enum
|
||||||
|
try:
|
||||||
|
role = Role[role_name] if role_name else Role.TEAM_MEMBER
|
||||||
|
except KeyError:
|
||||||
|
role = Role.TEAM_MEMBER
|
||||||
|
|
||||||
|
# Create new user with role and team
|
||||||
|
new_user = user_repo.create(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
company_id=g.user.company_id,
|
||||||
|
is_verified=auto_verify,
|
||||||
|
role=role,
|
||||||
|
team_id=team_id if team_id else None
|
||||||
|
)
|
||||||
|
new_user.set_password(password)
|
||||||
|
|
||||||
|
if not auto_verify:
|
||||||
|
# Generate verification token and send email
|
||||||
|
token = new_user.generate_verification_token()
|
||||||
|
verification_url = url_for('verify_email', token=token, _external=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from flask_mail import Mail
|
||||||
|
mail = Mail(current_app)
|
||||||
|
|
||||||
|
# Get branding for email
|
||||||
|
from models import BrandingSettings
|
||||||
|
branding = BrandingSettings.get_settings()
|
||||||
|
|
||||||
|
msg = Message(
|
||||||
|
f'Welcome to {branding.app_name} - Verify Your Email',
|
||||||
|
sender=(branding.app_name, current_app.config['MAIL_USERNAME']),
|
||||||
|
recipients=[email]
|
||||||
|
)
|
||||||
|
msg.body = f'''Welcome to {branding.app_name}!
|
||||||
|
|
||||||
|
Your administrator has created an account for you. Please verify your email address to activate your account.
|
||||||
|
|
||||||
|
Username: {username}
|
||||||
|
Company: {g.company.name}
|
||||||
|
|
||||||
|
Click the link below to verify your email:
|
||||||
|
{verification_url}
|
||||||
|
|
||||||
|
This link will expire in 24 hours.
|
||||||
|
|
||||||
|
If you did not expect this email, please ignore it.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
The {branding.app_name} Team
|
||||||
|
'''
|
||||||
|
mail.send(msg)
|
||||||
|
logger.info(f"Verification email sent to {email}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send verification email: {str(e)}")
|
||||||
|
flash('User created but verification email could not be sent. Please contact the user directly.', 'warning')
|
||||||
|
|
||||||
|
user_repo.save()
|
||||||
|
|
||||||
|
flash(f'User {username} created successfully!', 'success')
|
||||||
|
return redirect(url_for('users.admin_users'))
|
||||||
|
|
||||||
|
validator.flash_errors()
|
||||||
|
|
||||||
|
# Get all teams for the form (company-scoped)
|
||||||
|
teams = Team.query.filter_by(company_id=g.user.company_id).all()
|
||||||
|
roles = get_available_roles()
|
||||||
|
|
||||||
|
return render_template('create_user.html', title='Create User', teams=teams, roles=roles)
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.route('/edit/<int:user_id>', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
@company_required
|
||||||
|
def edit_user(user_id):
|
||||||
|
user_repo = UserRepository()
|
||||||
|
user = user_repo.get_by_id_and_company(user_id, g.user.company_id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
validator = FormValidator()
|
||||||
|
|
||||||
|
username = request.form.get('username')
|
||||||
|
email = request.form.get('email')
|
||||||
|
password = request.form.get('password')
|
||||||
|
|
||||||
|
# Get role and team
|
||||||
|
role_name = request.form.get('role')
|
||||||
|
team_id = request.form.get('team_id')
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
validator.validate_required(username, 'Username')
|
||||||
|
validator.validate_required(email, 'Email')
|
||||||
|
|
||||||
|
# Validate uniqueness (exclude current user)
|
||||||
|
if validator.is_valid():
|
||||||
|
if username != user.username:
|
||||||
|
validator.validate_unique(User, 'username', username, company_id=g.user.company_id)
|
||||||
|
if email != user.email:
|
||||||
|
validator.validate_unique(User, 'email', email, company_id=g.user.company_id)
|
||||||
|
|
||||||
|
if validator.is_valid():
|
||||||
|
# Convert role string to enum
|
||||||
|
try:
|
||||||
|
role = Role[role_name] if role_name else Role.TEAM_MEMBER
|
||||||
|
except KeyError:
|
||||||
|
role = Role.TEAM_MEMBER
|
||||||
|
|
||||||
|
user_repo.update(user,
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
role=role,
|
||||||
|
team_id=team_id if team_id else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if password:
|
||||||
|
user.set_password(password)
|
||||||
|
|
||||||
|
user_repo.save()
|
||||||
|
|
||||||
|
flash(f'User {username} updated successfully!', 'success')
|
||||||
|
return redirect(url_for('users.admin_users'))
|
||||||
|
|
||||||
|
validator.flash_errors()
|
||||||
|
|
||||||
|
# Get all teams for the form (company-scoped)
|
||||||
|
teams = Team.query.filter_by(company_id=g.user.company_id).all()
|
||||||
|
roles = get_available_roles()
|
||||||
|
|
||||||
|
return render_template('edit_user.html', title='Edit User', user=user, teams=teams, roles=roles)
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.route('/delete/<int:user_id>', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
@company_required
|
||||||
|
def delete_user(user_id):
|
||||||
|
user = User.query.filter_by(id=user_id, company_id=g.user.company_id).first_or_404()
|
||||||
|
|
||||||
|
# Prevent deleting yourself
|
||||||
|
if user.id == session.get('user_id'):
|
||||||
|
flash('You cannot delete your own account', 'error')
|
||||||
|
return redirect(url_for('users.admin_users'))
|
||||||
|
|
||||||
|
username = user.username
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if user owns any critical resources
|
||||||
|
owns_projects = Project.query.filter_by(created_by_id=user_id).count() > 0
|
||||||
|
owns_tasks = Task.query.filter_by(created_by_id=user_id).count() > 0
|
||||||
|
owns_subtasks = SubTask.query.filter_by(created_by_id=user_id).count() > 0
|
||||||
|
|
||||||
|
needs_ownership_transfer = owns_projects or owns_tasks or owns_subtasks
|
||||||
|
|
||||||
|
if needs_ownership_transfer:
|
||||||
|
# Find an alternative admin/supervisor to transfer ownership to
|
||||||
|
alternative_admin = User.query.filter(
|
||||||
|
User.company_id == g.user.company_id,
|
||||||
|
User.role.in_([Role.ADMIN, Role.SUPERVISOR]),
|
||||||
|
User.id != user_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if alternative_admin:
|
||||||
|
# Transfer ownership of projects to alternative admin
|
||||||
|
if owns_projects:
|
||||||
|
Project.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
|
||||||
|
|
||||||
|
# Transfer ownership of tasks to alternative admin
|
||||||
|
if owns_tasks:
|
||||||
|
Task.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
|
||||||
|
|
||||||
|
# Transfer ownership of subtasks to alternative admin
|
||||||
|
if owns_subtasks:
|
||||||
|
SubTask.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
|
||||||
|
else:
|
||||||
|
# No alternative admin found but user owns resources
|
||||||
|
flash('Cannot delete this user. They own resources but no other administrator or supervisor found to transfer ownership to.', 'error')
|
||||||
|
return redirect(url_for('users.admin_users'))
|
||||||
|
|
||||||
|
# Delete user-specific records that can be safely removed
|
||||||
|
TimeEntry.query.filter_by(user_id=user_id).delete()
|
||||||
|
WorkConfig.query.filter_by(user_id=user_id).delete()
|
||||||
|
UserPreferences.query.filter_by(user_id=user_id).delete()
|
||||||
|
|
||||||
|
# Delete user dashboards (cascades to widgets)
|
||||||
|
UserDashboard.query.filter_by(user_id=user_id).delete()
|
||||||
|
|
||||||
|
# Clear task and subtask assignments
|
||||||
|
Task.query.filter_by(assigned_to_id=user_id).update({'assigned_to_id': None})
|
||||||
|
SubTask.query.filter_by(assigned_to_id=user_id).update({'assigned_to_id': None})
|
||||||
|
|
||||||
|
# Now safe to delete the user
|
||||||
|
db.session.delete(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if needs_ownership_transfer and alternative_admin:
|
||||||
|
flash(f'User {username} deleted successfully. Projects and tasks transferred to {alternative_admin.username}', 'success')
|
||||||
|
else:
|
||||||
|
flash(f'User {username} deleted successfully.', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error deleting user {user_id}: {str(e)}")
|
||||||
|
flash(f'Error deleting user: {str(e)}', 'error')
|
||||||
|
|
||||||
|
return redirect(url_for('users.admin_users'))
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.route('/toggle-status/<int:user_id>', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
@company_required
|
||||||
|
def toggle_user_status(user_id):
|
||||||
|
"""Toggle user active/blocked status"""
|
||||||
|
user = User.query.filter_by(id=user_id, company_id=g.user.company_id).first_or_404()
|
||||||
|
|
||||||
|
# Prevent blocking yourself
|
||||||
|
if user.id == g.user.id:
|
||||||
|
flash('You cannot block your own account', 'error')
|
||||||
|
return redirect(url_for('users.admin_users'))
|
||||||
|
|
||||||
|
# Toggle the blocked status
|
||||||
|
user.is_blocked = not user.is_blocked
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
status = 'blocked' if user.is_blocked else 'unblocked'
|
||||||
|
flash(f'User {user.username} has been {status}', 'success')
|
||||||
|
|
||||||
|
return redirect(url_for('users.admin_users'))
|
||||||
|
|
||||||
|
|
||||||
|
# System Admin User Routes
|
||||||
|
@users_bp.route('/system-admin')
|
||||||
|
@system_admin_required
|
||||||
|
def system_admin_users():
|
||||||
|
"""System admin view of all users across all companies"""
|
||||||
|
# Get filter parameters
|
||||||
|
company_id = request.args.get('company_id', type=int)
|
||||||
|
search_query = request.args.get('search', '')
|
||||||
|
filter_type = request.args.get('filter', '')
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = 50
|
||||||
|
|
||||||
|
# Build query that returns tuples of (User, company_name)
|
||||||
|
query = db.session.query(User, Company.name).join(Company)
|
||||||
|
|
||||||
|
# Apply company filter
|
||||||
|
if company_id:
|
||||||
|
query = query.filter(User.company_id == company_id)
|
||||||
|
|
||||||
|
# Apply search filter
|
||||||
|
if search_query:
|
||||||
|
query = query.filter(
|
||||||
|
db.or_(
|
||||||
|
User.username.ilike(f'%{search_query}%'),
|
||||||
|
User.email.ilike(f'%{search_query}%')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply type filter
|
||||||
|
if filter_type == 'system_admins':
|
||||||
|
query = query.filter(User.role == Role.SYSTEM_ADMIN)
|
||||||
|
elif filter_type == 'admins':
|
||||||
|
query = query.filter(User.role == Role.ADMIN)
|
||||||
|
elif filter_type == 'blocked':
|
||||||
|
query = query.filter(User.is_blocked == True)
|
||||||
|
elif filter_type == 'unverified':
|
||||||
|
query = query.filter(User.is_verified == False)
|
||||||
|
elif filter_type == 'freelancers':
|
||||||
|
query = query.filter(Company.is_personal == True)
|
||||||
|
|
||||||
|
# Order by company name and username
|
||||||
|
query = query.order_by(Company.name, User.username)
|
||||||
|
|
||||||
|
# Paginate the results
|
||||||
|
try:
|
||||||
|
users = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
# Debug log
|
||||||
|
if users.items:
|
||||||
|
logger.info(f"First item type: {type(users.items[0])}")
|
||||||
|
logger.info(f"First item: {users.items[0]}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error paginating users: {str(e)}")
|
||||||
|
# Fallback to empty pagination
|
||||||
|
from flask_sqlalchemy import Pagination
|
||||||
|
users = Pagination(query=None, page=1, per_page=per_page, total=0, items=[])
|
||||||
|
|
||||||
|
# Get all companies for filter dropdown
|
||||||
|
companies = Company.query.order_by(Company.name).all()
|
||||||
|
|
||||||
|
# Calculate statistics
|
||||||
|
all_users = User.query.all()
|
||||||
|
stats = {
|
||||||
|
'total_users': len(all_users),
|
||||||
|
'verified_users': len([u for u in all_users if u.is_verified]),
|
||||||
|
'blocked_users': len([u for u in all_users if u.is_blocked]),
|
||||||
|
'system_admins': len([u for u in all_users if u.role == Role.SYSTEM_ADMIN]),
|
||||||
|
'company_admins': len([u for u in all_users if u.role == Role.ADMIN]),
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template('system_admin_users.html',
|
||||||
|
title='System User Management',
|
||||||
|
users=users,
|
||||||
|
companies=companies,
|
||||||
|
stats=stats,
|
||||||
|
selected_company_id=company_id,
|
||||||
|
search_query=search_query,
|
||||||
|
current_filter=filter_type)
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.route('/system-admin/<int:user_id>/edit', methods=['GET', 'POST'])
|
||||||
|
@system_admin_required
|
||||||
|
def system_admin_edit_user(user_id):
|
||||||
|
"""System admin edit any user"""
|
||||||
|
user = User.query.get_or_404(user_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
username = request.form.get('username')
|
||||||
|
email = request.form.get('email')
|
||||||
|
password = request.form.get('password')
|
||||||
|
role_name = request.form.get('role')
|
||||||
|
company_id = request.form.get('company_id', type=int)
|
||||||
|
team_id = request.form.get('team_id', type=int)
|
||||||
|
is_verified = 'is_verified' in request.form
|
||||||
|
is_blocked = 'is_blocked' in request.form
|
||||||
|
|
||||||
|
# Validate input
|
||||||
|
validator = FormValidator()
|
||||||
|
|
||||||
|
validator.validate_required(username, 'Username')
|
||||||
|
validator.validate_required(email, 'Email')
|
||||||
|
|
||||||
|
# Validate uniqueness (exclude current user)
|
||||||
|
if validator.is_valid():
|
||||||
|
if username != user.username:
|
||||||
|
validator.validate_unique(User, 'username', username, company_id=user.company_id)
|
||||||
|
if email != user.email:
|
||||||
|
validator.validate_unique(User, 'email', email, company_id=user.company_id)
|
||||||
|
|
||||||
|
# Prevent removing the last system admin
|
||||||
|
if validator.is_valid() and user.role == Role.SYSTEM_ADMIN and role_name != 'SYSTEM_ADMIN':
|
||||||
|
system_admin_count = User.query.filter_by(role=Role.SYSTEM_ADMIN, is_blocked=False).count()
|
||||||
|
if system_admin_count <= 1:
|
||||||
|
validator.errors.add('Cannot remove the last system administrator')
|
||||||
|
|
||||||
|
if validator.is_valid():
|
||||||
|
user.username = username
|
||||||
|
user.email = email
|
||||||
|
user.is_verified = is_verified
|
||||||
|
user.is_blocked = is_blocked
|
||||||
|
|
||||||
|
# Update company and team
|
||||||
|
if company_id:
|
||||||
|
user.company_id = company_id
|
||||||
|
if team_id:
|
||||||
|
user.team_id = team_id
|
||||||
|
else:
|
||||||
|
user.team_id = None # Clear team if not selected
|
||||||
|
|
||||||
|
# Update role
|
||||||
|
try:
|
||||||
|
user.role = Role[role_name] if role_name else Role.TEAM_MEMBER
|
||||||
|
except KeyError:
|
||||||
|
user.role = Role.TEAM_MEMBER
|
||||||
|
|
||||||
|
if password:
|
||||||
|
user.set_password(password)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'User {username} updated successfully!', 'success')
|
||||||
|
return redirect(url_for('users.system_admin_users'))
|
||||||
|
|
||||||
|
validator.flash_errors()
|
||||||
|
|
||||||
|
# Get all companies and teams for the form
|
||||||
|
companies = Company.query.order_by(Company.name).all()
|
||||||
|
teams = Team.query.filter_by(company_id=user.company_id).order_by(Team.name).all()
|
||||||
|
|
||||||
|
return render_template('system_admin_edit_user.html',
|
||||||
|
title='Edit User (System Admin)',
|
||||||
|
user=user,
|
||||||
|
companies=companies,
|
||||||
|
teams=teams,
|
||||||
|
roles=list(Role),
|
||||||
|
Role=Role)
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.route('/system-admin/<int:user_id>/delete', methods=['POST'])
|
||||||
|
@system_admin_required
|
||||||
|
def system_admin_delete_user(user_id):
|
||||||
|
"""System admin delete any user"""
|
||||||
|
user = User.query.get_or_404(user_id)
|
||||||
|
|
||||||
|
# Prevent deleting yourself
|
||||||
|
if user.id == g.user.id:
|
||||||
|
flash('You cannot delete your own account', 'error')
|
||||||
|
return redirect(url_for('users.system_admin_users'))
|
||||||
|
|
||||||
|
# Prevent deleting the last system admin
|
||||||
|
if user.role == Role.SYSTEM_ADMIN:
|
||||||
|
system_admin_count = User.query.filter_by(role=Role.SYSTEM_ADMIN).count()
|
||||||
|
if system_admin_count <= 1:
|
||||||
|
flash('Cannot delete the last system administrator', 'error')
|
||||||
|
return redirect(url_for('users.system_admin_users'))
|
||||||
|
|
||||||
|
username = user.username
|
||||||
|
company_name = user.company.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if this is the last admin/supervisor in the company
|
||||||
|
admin_count = User.query.filter(
|
||||||
|
User.company_id == user.company_id,
|
||||||
|
User.role.in_([Role.ADMIN, Role.SUPERVISOR]),
|
||||||
|
User.id != user_id
|
||||||
|
).count()
|
||||||
|
|
||||||
|
if admin_count == 0:
|
||||||
|
# This is the last admin - need to handle company data
|
||||||
|
flash(f'User {username} is the last administrator in {company_name}. Company data will need to be handled.', 'warning')
|
||||||
|
# For now, just prevent deletion
|
||||||
|
return redirect(url_for('users.system_admin_users'))
|
||||||
|
|
||||||
|
# Otherwise proceed with normal deletion
|
||||||
|
# Delete user-specific records
|
||||||
|
TimeEntry.query.filter_by(user_id=user_id).delete()
|
||||||
|
WorkConfig.query.filter_by(user_id=user_id).delete()
|
||||||
|
UserPreferences.query.filter_by(user_id=user_id).delete()
|
||||||
|
UserDashboard.query.filter_by(user_id=user_id).delete()
|
||||||
|
|
||||||
|
# Clear assignments
|
||||||
|
Task.query.filter_by(assigned_to_id=user_id).update({'assigned_to_id': None})
|
||||||
|
SubTask.query.filter_by(assigned_to_id=user_id).update({'assigned_to_id': None})
|
||||||
|
|
||||||
|
# Transfer ownership of created items
|
||||||
|
alternative_admin = User.query.filter(
|
||||||
|
User.company_id == user.company_id,
|
||||||
|
User.role.in_([Role.ADMIN, Role.SUPERVISOR]),
|
||||||
|
User.id != user_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if alternative_admin:
|
||||||
|
Project.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
|
||||||
|
Task.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
|
||||||
|
SubTask.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
|
||||||
|
ProjectCategory.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id})
|
||||||
|
|
||||||
|
# Delete the user
|
||||||
|
db.session.delete(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(f'User {username} from {company_name} deleted successfully!', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"Error deleting user {user_id}: {str(e)}")
|
||||||
|
flash(f'Error deleting user: {str(e)}', 'error')
|
||||||
|
|
||||||
|
return redirect(url_for('users.system_admin_users'))
|
||||||
75
routes/users_api.py
Normal file
75
routes/users_api.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
User API endpoints
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, g
|
||||||
|
from models import db, User, Role
|
||||||
|
from routes.auth import system_admin_required, role_required
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
users_api_bp = Blueprint('users_api', __name__, url_prefix='/api')
|
||||||
|
|
||||||
|
|
||||||
|
@users_api_bp.route('/system-admin/users/<int:user_id>/toggle-block', methods=['POST'])
|
||||||
|
@system_admin_required
|
||||||
|
def api_toggle_user_block(user_id):
|
||||||
|
"""API: Toggle user blocked status (System Admin only)"""
|
||||||
|
user = User.query.get_or_404(user_id)
|
||||||
|
|
||||||
|
# Safety check: prevent blocking yourself
|
||||||
|
if user.id == g.user.id:
|
||||||
|
return jsonify({'error': 'Cannot block your own account'}), 400
|
||||||
|
|
||||||
|
# Safety check: prevent blocking the last system admin
|
||||||
|
if user.role == Role.SYSTEM_ADMIN and not user.is_blocked:
|
||||||
|
system_admin_count = User.query.filter_by(role=Role.SYSTEM_ADMIN, is_blocked=False).count()
|
||||||
|
if system_admin_count <= 1:
|
||||||
|
return jsonify({'error': 'Cannot block the last system administrator'}), 400
|
||||||
|
|
||||||
|
user.is_blocked = not user.is_blocked
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'id': user.id,
|
||||||
|
'username': user.username,
|
||||||
|
'is_blocked': user.is_blocked,
|
||||||
|
'message': f'User {"blocked" if user.is_blocked else "unblocked"} successfully'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@users_api_bp.route('/search/users')
|
||||||
|
@role_required(Role.TEAM_MEMBER)
|
||||||
|
def search_users():
|
||||||
|
"""Search for users within the company"""
|
||||||
|
query = request.args.get('q', '').strip()
|
||||||
|
exclude_id = request.args.get('exclude', type=int)
|
||||||
|
|
||||||
|
if not query or len(query) < 2:
|
||||||
|
return jsonify({'users': []})
|
||||||
|
|
||||||
|
# Search users in the same company
|
||||||
|
users_query = User.query.filter(
|
||||||
|
User.company_id == g.user.company_id,
|
||||||
|
or_(
|
||||||
|
User.username.ilike(f'%{query}%'),
|
||||||
|
User.email.ilike(f'%{query}%')
|
||||||
|
),
|
||||||
|
User.is_blocked == False,
|
||||||
|
User.is_verified == True
|
||||||
|
)
|
||||||
|
|
||||||
|
if exclude_id:
|
||||||
|
users_query = users_query.filter(User.id != exclude_id)
|
||||||
|
|
||||||
|
users = users_query.limit(10).all()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'users': [{
|
||||||
|
'id': user.id,
|
||||||
|
'username': user.username,
|
||||||
|
'email': user.email,
|
||||||
|
'avatar_url': user.get_avatar_url(32),
|
||||||
|
'role': user.role.value,
|
||||||
|
'team': user.team.name if user.team else None
|
||||||
|
} for user in users]
|
||||||
|
})
|
||||||
64
startup.sh
64
startup.sh
@@ -11,40 +11,7 @@ while ! pg_isready -h db -p 5432 -U "$POSTGRES_USER" > /dev/null 2>&1; do
|
|||||||
done
|
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>
|
||||||
|
|||||||
1628
templates/index.html
1628
templates/index.html
File diff suppressed because it is too large
Load Diff
938
templates/index_old.html
Normal file
938
templates/index_old.html
Normal file
@@ -0,0 +1,938 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if not g.user %}
|
||||||
|
|
||||||
|
<!-- Decadent Splash Page -->
|
||||||
|
<div class="splash-container">
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="splash-hero">
|
||||||
|
<div class="hero-content">
|
||||||
|
<h1 class="hero-title">Transform Your Productivity</h1>
|
||||||
|
<p class="hero-subtitle">Experience the future of time management with {{ g.branding.app_name if g.branding else 'TimeTrack' }}'s intelligent tracking system</p>
|
||||||
|
<div class="cta-buttons">
|
||||||
|
<a href="{{ url_for('register') }}" class="btn-primary">Get Started Free</a>
|
||||||
|
<a href="{{ url_for('login') }}" class="btn-secondary">Sign In</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-visual">
|
||||||
|
<div class="floating-clock">
|
||||||
|
<div class="clock-face">
|
||||||
|
<div class="hour-hand"></div>
|
||||||
|
<div class="minute-hand"></div>
|
||||||
|
<div class="second-hand"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Features Grid -->
|
||||||
|
<section class="features-grid">
|
||||||
|
<h2 class="section-title">Powerful Features for Modern Teams</h2>
|
||||||
|
<div class="feature-cards">
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">⚡</div>
|
||||||
|
<h3>Lightning Fast</h3>
|
||||||
|
<p>Start tracking in seconds with our intuitive one-click interface</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📊</div>
|
||||||
|
<h3>Advanced Analytics</h3>
|
||||||
|
<p>Gain insights with comprehensive reports and visual dashboards</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🏃♂️</div>
|
||||||
|
<h3>Sprint Management</h3>
|
||||||
|
<p>Organize work into sprints with agile project tracking</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">👥</div>
|
||||||
|
<h3>Team Collaboration</h3>
|
||||||
|
<p>Manage teams, projects, and resources all in one place</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🔒</div>
|
||||||
|
<h3>Enterprise Security</h3>
|
||||||
|
<p>Bank-level encryption with role-based access control</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🌐</div>
|
||||||
|
<h3>Multi-Company Support</h3>
|
||||||
|
<p>Perfect for agencies managing multiple client accounts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Why Choose Section -->
|
||||||
|
<section class="statistics">
|
||||||
|
<h2 class="section-title">Why Choose {{ g.branding.app_name if g.branding else 'TimeTrack' }}?</h2>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number">100%</div>
|
||||||
|
<div class="stat-label">Free & Open Source</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number">∞</div>
|
||||||
|
<div class="stat-label">Unlimited Tracking</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number">0</div>
|
||||||
|
<div class="stat-label">Hidden Fees</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number">24/7</div>
|
||||||
|
<div class="stat-label">Always Available</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Getting Started Section -->
|
||||||
|
<section class="testimonials">
|
||||||
|
<h2 class="section-title">Get Started in Minutes</h2>
|
||||||
|
<div class="testimonial-grid">
|
||||||
|
<div class="testimonial-card">
|
||||||
|
<div class="feature-icon">1️⃣</div>
|
||||||
|
<h3>Sign Up</h3>
|
||||||
|
<p>Create your free account in seconds. No credit card required.</p>
|
||||||
|
</div>
|
||||||
|
<div class="testimonial-card">
|
||||||
|
<div class="feature-icon">2️⃣</div>
|
||||||
|
<h3>Set Up Your Workspace</h3>
|
||||||
|
<p>Add your company, teams, and projects to organize your time tracking.</p>
|
||||||
|
</div>
|
||||||
|
<div class="testimonial-card">
|
||||||
|
<div class="feature-icon">3️⃣</div>
|
||||||
|
<h3>Start Tracking</h3>
|
||||||
|
<p>Click "Arrive" to start tracking, "Leave" when done. It's that simple!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Open Source Section -->
|
||||||
|
<section class="pricing">
|
||||||
|
<h2 class="section-title">Forever Free, Forever Open</h2>
|
||||||
|
<div class="pricing-cards">
|
||||||
|
<div class="pricing-card featured">
|
||||||
|
<div class="badge">100% Free</div>
|
||||||
|
<h3>{{ g.branding.app_name if g.branding else 'TimeTrack' }} Community</h3>
|
||||||
|
<div class="price">$0<span>/forever</span></div>
|
||||||
|
<ul class="pricing-features">
|
||||||
|
<li>✓ Unlimited users</li>
|
||||||
|
<li>✓ All features included</li>
|
||||||
|
<li>✓ Time tracking & analytics</li>
|
||||||
|
<li>✓ Sprint management</li>
|
||||||
|
<li>✓ Team collaboration</li>
|
||||||
|
<li>✓ Project management</li>
|
||||||
|
<li>✓ Self-hosted option</li>
|
||||||
|
<li>✓ No restrictions</li>
|
||||||
|
</ul>
|
||||||
|
<a href="{{ url_for('register') }}" class="btn-pricing">Get Started Free</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="text-align: center; margin-top: 2rem; color: #666;">
|
||||||
|
The software {{ g.branding.app_name if g.branding else 'TimeTrack' }} runs is open source software.<br />
|
||||||
|
Host it yourself or use our free hosted version.<br />
|
||||||
|
The source is available on GitHub:
|
||||||
|
<a href="https://github.com/nullmedium/TimeTrack" target="_blank">https://github.com/nullmedium/TimeTrack</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Final CTA -->
|
||||||
|
<section class="final-cta">
|
||||||
|
<h2>Ready to Take Control of Your Time?</h2>
|
||||||
|
<p>Start tracking your time effectively today - no strings attached</p>
|
||||||
|
<a href="{{ url_for('register') }}" class="btn-primary large">Create Free Account</a>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<!-- Include the modern time tracking interface from time_tracking.html -->
|
||||||
|
<div class="time-tracking-container">
|
||||||
|
<!-- Header Section -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1 class="page-title">
|
||||||
|
<span class="page-icon">⏱️</span>
|
||||||
|
Time Tracking
|
||||||
|
</h1>
|
||||||
|
<p class="page-subtitle">Track your work hours efficiently</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button id="manual-entry-btn" class="btn btn-secondary">
|
||||||
|
<span class="icon">📝</span>
|
||||||
|
Manual Entry
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('analytics') }}" class="btn btn-secondary">
|
||||||
|
<span class="icon">📊</span>
|
||||||
|
View Analytics
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timer Section -->
|
||||||
|
<div class="timer-section">
|
||||||
|
{% if active_entry %}
|
||||||
|
<!-- Active Timer -->
|
||||||
|
<div class="timer-card active">
|
||||||
|
<div class="timer-display">
|
||||||
|
<div class="timer-value" id="timer"
|
||||||
|
data-start="{{ active_entry.arrival_time.timestamp() }}"
|
||||||
|
data-breaks="{{ active_entry.total_break_duration }}"
|
||||||
|
data-paused="{{ 'true' if active_entry.is_paused else 'false' }}">
|
||||||
|
00:00:00
|
||||||
|
</div>
|
||||||
|
<div class="timer-status">
|
||||||
|
{% if active_entry.is_paused %}
|
||||||
|
<span class="status-badge paused">On Break</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge active">Working</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timer-info">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Started:</span>
|
||||||
|
<span class="info-value">{{ active_entry.arrival_time|format_datetime }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if active_entry.project %}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Project:</span>
|
||||||
|
<span class="info-value project-badge" style="background-color: {{ active_entry.project.color or '#667eea' }}">
|
||||||
|
{{ active_entry.project.code }} - {{ active_entry.project.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if active_entry.task %}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Task:</span>
|
||||||
|
<span class="info-value task-badge">
|
||||||
|
{{ active_entry.task.title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if active_entry.notes %}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Notes:</span>
|
||||||
|
<span class="info-value">{{ active_entry.notes }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if active_entry.is_paused %}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Break started:</span>
|
||||||
|
<span class="info-value">{{ active_entry.pause_start_time|format_time }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if active_entry.total_break_duration > 0 %}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Total breaks:</span>
|
||||||
|
<span class="info-value">{{ active_entry.total_break_duration|format_duration }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timer-actions">
|
||||||
|
<button id="pause-btn" class="btn {% if active_entry.is_paused %}btn-success{% else %}btn-warning{% endif %}"
|
||||||
|
data-id="{{ active_entry.id }}">
|
||||||
|
{% if active_entry.is_paused %}
|
||||||
|
<span class="icon">▶️</span>
|
||||||
|
Resume Work
|
||||||
|
{% else %}
|
||||||
|
<span class="icon">⏸️</span>
|
||||||
|
Take Break
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
<button id="leave-btn" class="btn btn-danger" data-id="{{ active_entry.id }}">
|
||||||
|
<span class="icon">⏹️</span>
|
||||||
|
Stop Working
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Inactive Timer -->
|
||||||
|
<div class="timer-card inactive">
|
||||||
|
<div class="start-work-container">
|
||||||
|
<h2>Start Tracking Time</h2>
|
||||||
|
<p>Select a project and task to begin tracking your work</p>
|
||||||
|
|
||||||
|
<form id="start-work-form" class="modern-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="project-select" class="form-label">
|
||||||
|
Project <span class="optional-badge">Optional</span>
|
||||||
|
</label>
|
||||||
|
<select id="project-select" name="project_id" class="form-control">
|
||||||
|
<option value="">No specific project</option>
|
||||||
|
{% for project in available_projects %}
|
||||||
|
<option value="{{ project.id }}" data-color="{{ project.color or '#667eea' }}">
|
||||||
|
{{ project.code }} - {{ project.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="task-select" class="form-label">
|
||||||
|
Task <span class="optional-badge">Optional</span>
|
||||||
|
</label>
|
||||||
|
<select id="task-select" name="task_id" class="form-control" disabled>
|
||||||
|
<option value="">Select a project first</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="work-notes" class="form-label">
|
||||||
|
Notes <span class="optional-badge">Optional</span>
|
||||||
|
</label>
|
||||||
|
<textarea id="work-notes" name="notes" class="form-control"
|
||||||
|
rows="2" placeholder="What are you working on?"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" id="arrive-btn" class="btn btn-primary btn-large">
|
||||||
|
<span class="icon">▶️</span>
|
||||||
|
Start Working
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
{% if today_hours is defined %}
|
||||||
|
<div class="stats-section">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ today_hours|format_duration }}</div>
|
||||||
|
<div class="stat-label">Today</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ week_hours|format_duration }}</div>
|
||||||
|
<div class="stat-label">This Week</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ month_hours|format_duration }}</div>
|
||||||
|
<div class="stat-label">This Month</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ active_projects|length if active_projects else 0 }}</div>
|
||||||
|
<div class="stat-label">Active Projects</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Recent Entries -->
|
||||||
|
<div class="entries-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<span class="icon">📋</span>
|
||||||
|
Recent Time Entries
|
||||||
|
</h2>
|
||||||
|
<div class="view-toggle">
|
||||||
|
<button class="toggle-btn active" data-view="list">
|
||||||
|
<span class="icon">📝</span>
|
||||||
|
List
|
||||||
|
</button>
|
||||||
|
<button class="toggle-btn" data-view="grid">
|
||||||
|
<span class="icon">📊</span>
|
||||||
|
Grid
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List View -->
|
||||||
|
<div id="list-view" class="view-container active">
|
||||||
|
{% if history %}
|
||||||
|
<div class="entries-table-container">
|
||||||
|
<table class="entries-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Project / Task</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Break</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in history %}
|
||||||
|
<tr data-entry-id="{{ entry.id }}" class="entry-row">
|
||||||
|
<td>
|
||||||
|
<div class="date-cell">
|
||||||
|
<span class="date-day">{{ entry.arrival_time.strftime('%d') }}</span>
|
||||||
|
<span class="date-month">{{ entry.arrival_time.strftime('%b') }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="time-cell">
|
||||||
|
<span class="time-start">{{ entry.arrival_time|format_time }}</span>
|
||||||
|
<span class="time-separator">→</span>
|
||||||
|
<span class="time-end">{{ entry.departure_time|format_time if entry.departure_time else 'Active' }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="project-task-cell">
|
||||||
|
{% if entry.project %}
|
||||||
|
<span class="project-tag" style="background-color: {{ entry.project.color or '#667eea' }}">
|
||||||
|
{{ entry.project.code }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if entry.task %}
|
||||||
|
<span class="task-name">{{ entry.task.title }}</span>
|
||||||
|
{% elif entry.project %}
|
||||||
|
<span class="project-name">{{ entry.project.name }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="no-project">No project</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="duration-badge">
|
||||||
|
{{ entry.duration|format_duration if entry.duration is not none else 'In progress' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="break-duration">
|
||||||
|
{{ entry.total_break_duration|format_duration if entry.total_break_duration else '-' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="notes-preview" title="{{ entry.notes or '' }}">
|
||||||
|
{{ entry.notes[:30] + '...' if entry.notes and entry.notes|length > 30 else entry.notes or '-' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="actions-cell">
|
||||||
|
{% if entry.departure_time and not active_entry %}
|
||||||
|
<button class="btn-icon resume-work-btn" data-id="{{ entry.id }}" title="Resume">
|
||||||
|
<span class="icon">🔄</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn-icon edit-entry-btn" data-id="{{ entry.id }}" title="Edit">
|
||||||
|
<span class="icon">✏️</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon delete-entry-btn" data-id="{{ entry.id }}" title="Delete">
|
||||||
|
<span class="icon">🗑️</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">📭</div>
|
||||||
|
<h3>No time entries yet</h3>
|
||||||
|
<p>Start tracking your time to see entries here</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid View -->
|
||||||
|
<div id="grid-view" class="view-container">
|
||||||
|
<div class="entries-grid">
|
||||||
|
{% for entry in history %}
|
||||||
|
<div class="entry-card" data-entry-id="{{ entry.id }}">
|
||||||
|
<div class="entry-header">
|
||||||
|
<div class="entry-date">
|
||||||
|
{{ entry.arrival_time.strftime('%d %b %Y') }}
|
||||||
|
</div>
|
||||||
|
<div class="entry-duration">
|
||||||
|
{{ entry.duration|format_duration if entry.duration is not none else 'Active' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="entry-body">
|
||||||
|
{% if entry.project %}
|
||||||
|
<div class="entry-project" style="border-left-color: {{ entry.project.color or '#667eea' }}">
|
||||||
|
<strong>{{ entry.project.code }}</strong> - {{ entry.project.name }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if entry.task %}
|
||||||
|
<div class="entry-task">
|
||||||
|
<span class="icon">📋</span> {{ entry.task.title }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="entry-time">
|
||||||
|
<span class="icon">🕐</span>
|
||||||
|
{{ entry.arrival_time|format_time }} - {{ entry.departure_time|format_time if entry.departure_time else 'Active' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if entry.notes %}
|
||||||
|
<div class="entry-notes">
|
||||||
|
<span class="icon">📝</span>
|
||||||
|
{{ entry.notes }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="entry-footer">
|
||||||
|
<button class="btn-sm edit-entry-btn" data-id="{{ entry.id }}">Edit</button>
|
||||||
|
<button class="btn-sm btn-danger delete-entry-btn" data-id="{{ entry.id }}">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Entry Modal -->
|
||||||
|
<div id="edit-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h3>Edit Time Entry</h3>
|
||||||
|
<form id="edit-entry-form">
|
||||||
|
<input type="hidden" id="edit-entry-id">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-arrival-date">Arrival Date:</label>
|
||||||
|
<input type="date" id="edit-arrival-date" required>
|
||||||
|
<small>Format: YYYY-MM-DD</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-arrival-time">Arrival Time (24h):</label>
|
||||||
|
<input type="time" id="edit-arrival-time" required step="1">
|
||||||
|
<small>Format: HH:MM (24-hour)</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-departure-date">Departure Date:</label>
|
||||||
|
<input type="date" id="edit-departure-date">
|
||||||
|
<small>Format: YYYY-MM-DD</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-departure-time">Departure Time (24h):</label>
|
||||||
|
<input type="time" id="edit-departure-time" step="1">
|
||||||
|
<small>Format: HH:MM (24-hour)</small>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Save Changes</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div id="delete-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h3>Confirm Deletion</h3>
|
||||||
|
<p>Are you sure you want to delete this time entry? This action cannot be undone.</p>
|
||||||
|
<input type="hidden" id="delete-entry-id">
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="confirm-delete" class="btn btn-danger">Delete</button>
|
||||||
|
<button id="cancel-delete" class="btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Time Entry Modal -->
|
||||||
|
<div id="manual-entry-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h3>Add Manual Time Entry</h3>
|
||||||
|
<form id="manual-entry-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="manual-project-select">Project (Optional):</label>
|
||||||
|
<select id="manual-project-select" name="project_id">
|
||||||
|
<option value="">No specific project</option>
|
||||||
|
{% for project in available_projects %}
|
||||||
|
<option value="{{ project.id }}">{{ project.code }} - {{ project.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="manual-start-date">Start Date:</label>
|
||||||
|
<input type="date" id="manual-start-date" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="manual-start-time">Start Time:</label>
|
||||||
|
<input type="time" id="manual-start-time" required step="1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="manual-end-date">End Date:</label>
|
||||||
|
<input type="date" id="manual-end-date" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="manual-end-time">End Time:</label>
|
||||||
|
<input type="time" id="manual-end-time" required step="1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="manual-break-minutes">Break Duration (minutes):</label>
|
||||||
|
<input type="number" id="manual-break-minutes" min="0" value="0" placeholder="Break time in minutes">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="manual-notes">Notes (Optional):</label>
|
||||||
|
<textarea id="manual-notes" name="notes" rows="3" placeholder="Description of work performed"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Add Entry</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Manual entry functionality
|
||||||
|
document.getElementById('manual-entry-btn').addEventListener('click', function() {
|
||||||
|
// Set default dates to today
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
document.getElementById('manual-start-date').value = today;
|
||||||
|
document.getElementById('manual-end-date').value = today;
|
||||||
|
document.getElementById('manual-entry-modal').style.display = 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manual entry form submission
|
||||||
|
document.getElementById('manual-entry-form').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const projectId = document.getElementById('manual-project-select').value || null;
|
||||||
|
const startDate = document.getElementById('manual-start-date').value;
|
||||||
|
const startTime = document.getElementById('manual-start-time').value;
|
||||||
|
const endDate = document.getElementById('manual-end-date').value;
|
||||||
|
const endTime = document.getElementById('manual-end-time').value;
|
||||||
|
const breakMinutes = parseInt(document.getElementById('manual-break-minutes').value) || 0;
|
||||||
|
const notes = document.getElementById('manual-notes').value;
|
||||||
|
|
||||||
|
// Validate end time is after start time
|
||||||
|
const startDateTime = new Date(`${startDate}T${startTime}`);
|
||||||
|
const endDateTime = new Date(`${endDate}T${endTime}`);
|
||||||
|
|
||||||
|
if (endDateTime <= startDateTime) {
|
||||||
|
alert('End time must be after start time');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send request to create manual entry
|
||||||
|
fetch('/api/manual-entry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
project_id: projectId,
|
||||||
|
start_date: startDate,
|
||||||
|
start_time: startTime,
|
||||||
|
end_date: endDate,
|
||||||
|
end_time: endTime,
|
||||||
|
break_minutes: breakMinutes,
|
||||||
|
notes: notes
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('manual-entry-modal').style.display = 'none';
|
||||||
|
location.reload(); // Refresh to show new entry
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('An error occurred while adding the manual entry');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit entry functionality
|
||||||
|
document.querySelectorAll('.edit-entry-btn').forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const entryId = this.getAttribute('data-id');
|
||||||
|
const row = document.querySelector(`tr[data-entry-id="${entryId}"]`);
|
||||||
|
const cells = row.querySelectorAll('td');
|
||||||
|
|
||||||
|
// Get date and time from the row
|
||||||
|
const dateStr = cells[0].textContent.trim();
|
||||||
|
const arrivalTimeStr = cells[2].textContent.trim(); // arrival time is now in column 2
|
||||||
|
const departureTimeStr = cells[3].textContent.trim(); // departure time is now in column 3
|
||||||
|
|
||||||
|
// Set values in the form
|
||||||
|
document.getElementById('edit-entry-id').value = entryId;
|
||||||
|
document.getElementById('edit-arrival-date').value = dateStr;
|
||||||
|
|
||||||
|
// Format time for input (HH:MM format)
|
||||||
|
document.getElementById('edit-arrival-time').value = arrivalTimeStr.substring(0, 5);
|
||||||
|
|
||||||
|
if (departureTimeStr && departureTimeStr !== 'Active') {
|
||||||
|
document.getElementById('edit-departure-date').value = dateStr;
|
||||||
|
document.getElementById('edit-departure-time').value = departureTimeStr.substring(0, 5);
|
||||||
|
} else {
|
||||||
|
document.getElementById('edit-departure-date').value = '';
|
||||||
|
document.getElementById('edit-departure-time').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
document.getElementById('edit-modal').style.display = 'block';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete entry functionality
|
||||||
|
document.querySelectorAll('.delete-entry-btn').forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const entryId = this.getAttribute('data-id');
|
||||||
|
document.getElementById('delete-entry-id').value = entryId;
|
||||||
|
document.getElementById('delete-modal').style.display = 'block';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modals when clicking the X
|
||||||
|
document.querySelectorAll('.close').forEach(closeBtn => {
|
||||||
|
closeBtn.addEventListener('click', function() {
|
||||||
|
this.closest('.modal').style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modals when clicking outside
|
||||||
|
window.addEventListener('click', function(event) {
|
||||||
|
if (event.target.classList.contains('modal')) {
|
||||||
|
event.target.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel delete
|
||||||
|
document.getElementById('cancel-delete').addEventListener('click', function() {
|
||||||
|
document.getElementById('delete-modal').style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Confirm delete
|
||||||
|
document.getElementById('confirm-delete').addEventListener('click', function() {
|
||||||
|
const entryId = document.getElementById('delete-entry-id').value;
|
||||||
|
|
||||||
|
fetch(`/api/delete/${entryId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Remove the row from the table
|
||||||
|
document.querySelector(`tr[data-entry-id="${entryId}"]`).remove();
|
||||||
|
// Close the modal
|
||||||
|
document.getElementById('delete-modal').style.display = 'none';
|
||||||
|
// Show success message
|
||||||
|
alert('Entry deleted successfully');
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('An error occurred while deleting the entry');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit edit form
|
||||||
|
document.getElementById('edit-entry-form').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const entryId = document.getElementById('edit-entry-id').value;
|
||||||
|
const arrivalDate = document.getElementById('edit-arrival-date').value;
|
||||||
|
const arrivalTime = document.getElementById('edit-arrival-time').value;
|
||||||
|
const departureDate = document.getElementById('edit-departure-date').value || '';
|
||||||
|
const departureTime = document.getElementById('edit-departure-time').value || '';
|
||||||
|
|
||||||
|
// Ensure we have seconds in the time strings
|
||||||
|
const arrivalTimeWithSeconds = arrivalTime.includes(':') ?
|
||||||
|
(arrivalTime.split(':').length === 2 ? arrivalTime + ':00' : arrivalTime) :
|
||||||
|
arrivalTime + ':00:00';
|
||||||
|
|
||||||
|
// Format datetime strings for the API (ISO 8601: YYYY-MM-DDTHH:MM:SS)
|
||||||
|
const arrivalDateTime = `${arrivalDate}T${arrivalTimeWithSeconds}`;
|
||||||
|
let departureDateTime = null;
|
||||||
|
|
||||||
|
if (departureDate && departureTime) {
|
||||||
|
const departureTimeWithSeconds = departureTime.includes(':') ?
|
||||||
|
(departureTime.split(':').length === 2 ? departureTime + ':00' : departureTime) :
|
||||||
|
departureTime + ':00:00';
|
||||||
|
departureDateTime = `${departureDate}T${departureTimeWithSeconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Sending update:', {
|
||||||
|
arrival_time: arrivalDateTime,
|
||||||
|
departure_time: departureDateTime
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send update request
|
||||||
|
fetch(`/api/update/${entryId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
arrival_time: arrivalDateTime,
|
||||||
|
departure_time: departureDateTime
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
return response.json().then(data => {
|
||||||
|
throw new Error(data.message || 'Server error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Close the modal
|
||||||
|
document.getElementById('edit-modal').style.display = 'none';
|
||||||
|
// Refresh the page to show updated data
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('An error occurred while updating the entry: ' + error.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.start-work-form {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-work-form .form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-work-form label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-work-form select,
|
||||||
|
.start-work-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-work-form select:focus,
|
||||||
|
.start-work-form textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4CAF50;
|
||||||
|
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-info {
|
||||||
|
color: #4CAF50;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tag {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tag + small {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-history td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-history .project-tag + small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-entry-btn {
|
||||||
|
background: #17a2b8;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 1rem;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-entry-btn:hover {
|
||||||
|
background: #138496;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: #fefefe;
|
||||||
|
margin: 5% auto;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #888;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 500px;
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 80%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal .form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal input,
|
||||||
|
.modal select,
|
||||||
|
.modal textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal input:focus,
|
||||||
|
.modal select:focus,
|
||||||
|
.modal textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4CAF50;
|
||||||
|
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
473
templates/invitations/list.html
Normal file
473
templates/invitations/list.html
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="invitations-container">
|
||||||
|
<!-- Header Section -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1 class="page-title">
|
||||||
|
<span class="page-icon">📨</span>
|
||||||
|
Invitations
|
||||||
|
</h1>
|
||||||
|
<p class="page-subtitle">Manage team invitations for {{ g.user.company.name }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="{{ url_for('invitations.send_invitation') }}" class="btn btn-primary">
|
||||||
|
<span class="icon">+</span>
|
||||||
|
Send New Invitation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics -->
|
||||||
|
<div class="stats-section">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ pending_invitations|length }}</div>
|
||||||
|
<div class="stat-label">Pending</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ accepted_invitations|length }}</div>
|
||||||
|
<div class="stat-label">Accepted</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ expired_invitations|length }}</div>
|
||||||
|
<div class="stat-label">Expired</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ (pending_invitations|length + accepted_invitations|length + expired_invitations|length) }}</div>
|
||||||
|
<div class="stat-label">Total Sent</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending Invitations -->
|
||||||
|
{% if pending_invitations %}
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<span class="icon">⏳</span>
|
||||||
|
Pending Invitations
|
||||||
|
</h2>
|
||||||
|
<div class="invitations-list">
|
||||||
|
{% for invitation in pending_invitations %}
|
||||||
|
<div class="invitation-card pending">
|
||||||
|
<div class="invitation-header">
|
||||||
|
<div class="invitation-info">
|
||||||
|
<h3 class="invitation-email">{{ invitation.email }}</h3>
|
||||||
|
<div class="invitation-meta">
|
||||||
|
<span class="meta-item">
|
||||||
|
<span class="icon">👤</span>
|
||||||
|
Role: {{ invitation.role }}
|
||||||
|
</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<span class="icon">📅</span>
|
||||||
|
Sent {{ invitation.created_at.strftime('%b %d, %Y') }}
|
||||||
|
</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<span class="icon">⏰</span>
|
||||||
|
Expires {{ invitation.expires_at.strftime('%b %d, %Y') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="invitation-actions">
|
||||||
|
<form method="POST" action="{{ url_for('invitations.resend_invitation', invitation_id=invitation.id) }}" style="display: inline;">
|
||||||
|
<button type="submit" class="btn btn-sm btn-secondary">
|
||||||
|
<span class="icon">🔄</span>
|
||||||
|
Resend
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="{{ url_for('invitations.revoke_invitation', invitation_id=invitation.id) }}" style="display: inline;">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Are you sure you want to revoke this invitation?');">
|
||||||
|
<span class="icon">❌</span>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="invitation-footer">
|
||||||
|
<span class="footer-text">Invited by {{ invitation.invited_by.username }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Accepted Invitations -->
|
||||||
|
{% if accepted_invitations %}
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<span class="icon">✅</span>
|
||||||
|
Accepted Invitations
|
||||||
|
</h2>
|
||||||
|
<div class="invitations-list">
|
||||||
|
{% for invitation in accepted_invitations %}
|
||||||
|
<div class="invitation-card accepted">
|
||||||
|
<div class="invitation-header">
|
||||||
|
<div class="invitation-info">
|
||||||
|
<h3 class="invitation-email">{{ invitation.email }}</h3>
|
||||||
|
<div class="invitation-meta">
|
||||||
|
<span class="meta-item">
|
||||||
|
<span class="icon">👤</span>
|
||||||
|
Joined as: {{ invitation.accepted_by.username }} ({{ invitation.role }})
|
||||||
|
</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<span class="icon">📅</span>
|
||||||
|
Accepted {{ invitation.accepted_at.strftime('%b %d, %Y') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="invitation-actions">
|
||||||
|
<a href="{{ url_for('users.view_user', user_id=invitation.accepted_by.id) }}" class="btn btn-sm btn-secondary">
|
||||||
|
<span class="icon">👁️</span>
|
||||||
|
View User
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="invitation-footer">
|
||||||
|
<span class="footer-text">Invited by {{ invitation.invited_by.username }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Expired Invitations -->
|
||||||
|
{% if expired_invitations %}
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<span class="icon">⏱️</span>
|
||||||
|
Expired Invitations
|
||||||
|
</h2>
|
||||||
|
<div class="invitations-list">
|
||||||
|
{% for invitation in expired_invitations %}
|
||||||
|
<div class="invitation-card expired">
|
||||||
|
<div class="invitation-header">
|
||||||
|
<div class="invitation-info">
|
||||||
|
<h3 class="invitation-email">{{ invitation.email }}</h3>
|
||||||
|
<div class="invitation-meta">
|
||||||
|
<span class="meta-item">
|
||||||
|
<span class="icon">👤</span>
|
||||||
|
Role: {{ invitation.role }}
|
||||||
|
</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<span class="icon">📅</span>
|
||||||
|
Expired {{ invitation.expires_at.strftime('%b %d, %Y') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="invitation-actions">
|
||||||
|
<form method="POST" action="{{ url_for('invitations.resend_invitation', invitation_id=invitation.id) }}" style="display: inline;">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">
|
||||||
|
<span class="icon">📤</span>
|
||||||
|
Send New Invitation
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="invitation-footer">
|
||||||
|
<span class="footer-text">Originally invited by {{ invitation.invited_by.username }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
{% if not pending_invitations and not accepted_invitations and not expired_invitations %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">📨</div>
|
||||||
|
<h3>No invitations yet</h3>
|
||||||
|
<p>Start building your team by sending invitations</p>
|
||||||
|
<a href="{{ url_for('invitations.send_invitation') }}" class="btn btn-primary">
|
||||||
|
<span class="icon">+</span>
|
||||||
|
Send First Invitation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.invitations-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header styles - reuse from other pages */
|
||||||
|
.page-header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
display: inline-block;
|
||||||
|
animation: float 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-10px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats section */
|
||||||
|
.stats-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
.section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Invitation cards */
|
||||||
|
.invitations-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-card.pending {
|
||||||
|
border-left: 4px solid #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-card.accepted {
|
||||||
|
border-left: 4px solid #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-card.expired {
|
||||||
|
border-left: 4px solid #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-card:hover {
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-email {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-footer {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: white;
|
||||||
|
color: #4b5563;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.invitations-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
378
templates/invitations/send.html
Normal file
378
templates/invitations/send.html
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="invitation-send-container">
|
||||||
|
<!-- Header Section -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1 class="page-title">
|
||||||
|
<span class="page-icon">✉️</span>
|
||||||
|
Send Invitation
|
||||||
|
</h1>
|
||||||
|
<p class="page-subtitle">Invite team members to join {{ g.user.company.name }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="{{ url_for('invitations.list_invitations') }}" class="btn btn-secondary">
|
||||||
|
<span class="icon">←</span>
|
||||||
|
Back to Invitations
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="content-wrapper">
|
||||||
|
<div class="card invitation-form-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<span class="icon">👥</span>
|
||||||
|
Invitation Details
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('invitations.send_invitation') }}" class="modern-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email" class="form-label">Email Address</label>
|
||||||
|
<input type="email" id="email" name="email" class="form-control"
|
||||||
|
placeholder="colleague@example.com" required autofocus>
|
||||||
|
<span class="form-hint">The email address where the invitation will be sent</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="role" class="form-label">Role</label>
|
||||||
|
<select id="role" name="role" class="form-control">
|
||||||
|
{% for role in roles %}
|
||||||
|
<option value="{{ role }}" {% if role == 'Team Member' %}selected{% endif %}>
|
||||||
|
{{ role }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<span class="form-hint">The role this user will have when they join</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="custom_message" class="form-label">Personal Message (Optional)</label>
|
||||||
|
<textarea id="custom_message" name="custom_message" class="form-control"
|
||||||
|
rows="4" placeholder="Add a personal message to the invitation..."></textarea>
|
||||||
|
<span class="form-hint">This message will be included in the invitation email</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-panel">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-icon">📧</span>
|
||||||
|
<div class="info-content">
|
||||||
|
<h4>What happens next?</h4>
|
||||||
|
<ul>
|
||||||
|
<li>An email invitation will be sent immediately</li>
|
||||||
|
<li>The recipient will have 7 days to accept</li>
|
||||||
|
<li>They'll create their account using the invitation link</li>
|
||||||
|
<li>They'll automatically join {{ g.user.company.name }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<span class="icon">📤</span>
|
||||||
|
Send Invitation
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('invitations.list_invitations') }}" class="btn btn-ghost">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview Section -->
|
||||||
|
<div class="card preview-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<span class="icon">👁️</span>
|
||||||
|
Email Preview
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="email-preview">
|
||||||
|
<div class="preview-from">
|
||||||
|
<strong>From:</strong> {{ g.branding.app_name }} <{{ g.branding.email_from or 'noreply@timetrack.com' }}>
|
||||||
|
</div>
|
||||||
|
<div class="preview-to">
|
||||||
|
<strong>To:</strong> <span id="preview-email">colleague@example.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-subject">
|
||||||
|
<strong>Subject:</strong> Invitation to join {{ g.user.company.name }} on {{ g.branding.app_name }}
|
||||||
|
</div>
|
||||||
|
<div class="preview-body">
|
||||||
|
<p>Hello,</p>
|
||||||
|
<p>{{ g.user.username }} has invited you to join {{ g.user.company.name }} on {{ g.branding.app_name }}.</p>
|
||||||
|
<p id="preview-message" style="display: none;"></p>
|
||||||
|
<p>Click the link below to accept the invitation and create your account:</p>
|
||||||
|
<p><a href="#" class="preview-link">[Invitation Link]</a></p>
|
||||||
|
<p>This invitation will expire in 7 days.</p>
|
||||||
|
<p>Best regards,<br>The {{ g.branding.app_name }} Team</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.invitation-send-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.content-wrapper {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-form-card,
|
||||||
|
.preview-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-preview {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-preview > div {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-body {
|
||||||
|
border-bottom: none !important;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-link {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reuse existing styles */
|
||||||
|
.page-header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
display: inline-block;
|
||||||
|
animation: float 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-10px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: #1f2937;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
background-color: white;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content h4 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content li {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: white;
|
||||||
|
color: #4b5563;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
color: #374151;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Live preview updates
|
||||||
|
document.getElementById('email').addEventListener('input', function(e) {
|
||||||
|
document.getElementById('preview-email').textContent = e.target.value || 'colleague@example.com';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('custom_message').addEventListener('input', function(e) {
|
||||||
|
const previewMessage = document.getElementById('preview-message');
|
||||||
|
if (e.target.value.trim()) {
|
||||||
|
previewMessage.textContent = e.target.value;
|
||||||
|
previewMessage.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
previewMessage.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -129,31 +129,30 @@
|
|||||||
<ul>
|
<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('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
@@ -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>
|
||||||
172
templates/register_invitation.html
Normal file
172
templates/register_invitation.html
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Accept Invitation - {{ g.branding.app_name if g.branding else 'TimeTrack' }}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}">
|
||||||
|
<style>
|
||||||
|
.invitation-info {
|
||||||
|
background: linear-gradient(135deg, #667eea10 0%, #764ba210 100%);
|
||||||
|
border: 2px solid #667eea;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-company {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-details {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-message {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-message h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-message p {
|
||||||
|
margin: 0;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="auth-page">
|
||||||
|
<div class="auth-container">
|
||||||
|
<div class="auth-brand">
|
||||||
|
<h1>Welcome to {{ g.branding.app_name if g.branding else 'TimeTrack' }}</h1>
|
||||||
|
<p>Complete your registration</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<!-- Invitation Info -->
|
||||||
|
<div class="invitation-info">
|
||||||
|
<div class="invitation-company">{{ invitation.company.name }}</div>
|
||||||
|
<p>You've been invited to join this company</p>
|
||||||
|
<div class="invitation-details">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-icon">👤</span>
|
||||||
|
<span>Role: <strong>{{ invitation.role }}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-icon">✉️</span>
|
||||||
|
<span>Email: <strong>{{ invitation.email }}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-icon">👥</span>
|
||||||
|
<span>Invited by: <strong>{{ invitation.invited_by.username }}</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="welcome-message">
|
||||||
|
<h3>Create Your Account</h3>
|
||||||
|
<p>Choose a username and password to complete your registration</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('register_with_invitation', token=invitation.token) }}" class="auth-form">
|
||||||
|
<div class="form-group input-icon">
|
||||||
|
<i>👤</i>
|
||||||
|
<input type="text" id="username" name="username" class="form-control"
|
||||||
|
placeholder="Choose a username" required autofocus>
|
||||||
|
<label for="username">Username</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="input-icon readonly-field">
|
||||||
|
<i>📧</i>
|
||||||
|
<input type="email" value="{{ invitation.email }}" class="form-control" readonly disabled>
|
||||||
|
<label>Email Address</label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">This email was used for your invitation</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group input-icon">
|
||||||
|
<i>🔒</i>
|
||||||
|
<input type="password" id="password" name="password" class="form-control"
|
||||||
|
placeholder="Create a strong password" required>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<div id="password-strength" class="password-strength"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group input-icon">
|
||||||
|
<i>🔒</i>
|
||||||
|
<input type="password" id="confirm_password" name="confirm_password" class="form-control"
|
||||||
|
placeholder="Confirm your password" required>
|
||||||
|
<label for="confirm_password">Confirm Password</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="terms" required>
|
||||||
|
<label class="form-check-label" for="terms">
|
||||||
|
I agree to the Terms of Service and Privacy Policy
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-primary">Create Account & Join {{ invitation.company.name }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-links">
|
||||||
|
<p>Already have an account? <a href="{{ url_for('login') }}">Login here</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="verification-notice">
|
||||||
|
<p>✅ Your email is pre-verified through this invitation</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/auth-animations.js') }}"></script>
|
||||||
|
<style>
|
||||||
|
.readonly-field {
|
||||||
|
opacity: 0.7;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-field input {
|
||||||
|
background-color: #f3f4f6 !important;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
<!-- Form Actions -->
|
<!-- Form Actions -->
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
{% if is_super_admin %}
|
{% if is_super_admin %}
|
||||||
<a href="{{ url_for('admin_company') }}" class="btn btn-secondary">
|
<a href="{{ url_for('companies.admin_company') }}" class="btn btn-secondary">
|
||||||
← Back to Dashboard
|
← Back to Dashboard
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
<h1>{{ "Edit" if announcement else "Create" }} Announcement</h1>
|
<h1>{{ "Edit" if announcement else "Create" }} Announcement</h1>
|
||||||
<p class="subtitle">{{ "Update" if announcement else "Create new" }} system announcement for users</p>
|
<p class="subtitle">{{ "Update" if announcement else "Create new" }} system announcement for users</p>
|
||||||
<a href="{{ url_for('system_admin_announcements') }}" class="btn btn-secondary">
|
<a href="{{ url_for('announcements.index') }}" class="btn btn-secondary">
|
||||||
← Back to Announcements
|
← Back to Announcements
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
{{ "Update" if announcement else "Create" }} Announcement
|
{{ "Update" if announcement else "Create" }} Announcement
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('system_admin_announcements') }}" class="btn btn-secondary">Cancel</a>
|
<a href="{{ url_for('announcements.index') }}" class="btn btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="content-header">
|
<div class="content-header">
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<h1>System Announcements</h1>
|
<h1>System Announcements</h1>
|
||||||
<a href="{{ url_for('system_admin_announcement_new') }}" class="btn btn-md btn-primary">
|
<a href="{{ url_for('announcements.create') }}" class="btn btn-md btn-primary">
|
||||||
<i class="icon">➕</i> New Announcement
|
<i class="icon">➕</i> New Announcement
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,11 +75,11 @@
|
|||||||
<td>{{ announcement.created_at.strftime('%Y-%m-%d') }}</td>
|
<td>{{ announcement.created_at.strftime('%Y-%m-%d') }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<a href="{{ url_for('system_admin_announcement_edit', id=announcement.id) }}"
|
<a href="{{ url_for('announcements.edit', id=announcement.id) }}"
|
||||||
class="btn btn-sm btn-outline-primary" title="Edit">
|
class="btn btn-sm btn-outline-primary" title="Edit">
|
||||||
✏️
|
✏️
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="{{ url_for('system_admin_announcement_delete', id=announcement.id) }}"
|
<form method="POST" action="{{ url_for('announcements.delete', id=announcement.id) }}"
|
||||||
style="display: inline-block;"
|
style="display: inline-block;"
|
||||||
onsubmit="return confirm('Are you sure you want to delete this announcement?')">
|
onsubmit="return confirm('Are you sure you want to delete this announcement?')">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
|
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
|
||||||
@@ -99,13 +99,13 @@
|
|||||||
<div class="pagination-container">
|
<div class="pagination-container">
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{% if announcements.has_prev %}
|
{% if announcements.has_prev %}
|
||||||
<a href="{{ url_for('system_admin_announcements', page=announcements.prev_num) }}" class="page-link">« Previous</a>
|
<a href="{{ url_for('announcements.index', page=announcements.prev_num) }}" class="page-link">« Previous</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for page_num in announcements.iter_pages() %}
|
{% for page_num in announcements.iter_pages() %}
|
||||||
{% if page_num %}
|
{% if page_num %}
|
||||||
{% if page_num != announcements.page %}
|
{% if page_num != announcements.page %}
|
||||||
<a href="{{ url_for('system_admin_announcements', page=page_num) }}" class="page-link">{{ page_num }}</a>
|
<a href="{{ url_for('announcements.index', page=page_num) }}" class="page-link">{{ page_num }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="page-link current">{{ page_num }}</span>
|
<span class="page-link current">{{ page_num }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if announcements.has_next %}
|
{% if announcements.has_next %}
|
||||||
<a href="{{ url_for('system_admin_announcements', page=announcements.next_num) }}" class="page-link">Next »</a>
|
<a href="{{ url_for('announcements.index', page=announcements.next_num) }}" class="page-link">Next »</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<h3>No announcements found</h3>
|
<h3>No announcements found</h3>
|
||||||
<p>Create your first announcement to communicate with users.</p>
|
<p>Create your first announcement to communicate with users.</p>
|
||||||
<a href="{{ url_for('system_admin_announcement_new') }}" class="btn btn-primary">
|
<a href="{{ url_for('announcements.create') }}" class="btn btn-primary">
|
||||||
Create Announcement
|
Create Announcement
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="management-header">
|
<div class="management-header">
|
||||||
<h1>🎨 Branding Settings</h1>
|
<h1>🎨 Branding Settings</h1>
|
||||||
<div class="management-actions">
|
<div class="management-actions">
|
||||||
<a href="{{ url_for('system_admin_dashboard') }}" class="btn btn-secondary">← Back to Dashboard</a>
|
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">← Back to Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
<!-- Save Button -->
|
<!-- Save Button -->
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">💾 Save Branding Settings</button>
|
<button type="submit" class="btn btn-primary">💾 Save Branding Settings</button>
|
||||||
<a href="{{ url_for('system_admin_dashboard') }}" class="btn btn-secondary">Cancel</a>
|
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
<h1>🏢 System Admin - All Companies</h1>
|
<h1>🏢 System Admin - All Companies</h1>
|
||||||
<p class="subtitle">Manage companies across the entire system</p>
|
<p class="subtitle">Manage companies across the entire system</p>
|
||||||
<a href="{{ url_for('system_admin_dashboard') }}" class="btn btn-md btn-secondary">← Back to Dashboard</a>
|
<div class="header-actions">
|
||||||
|
<a href="/setup" class="btn btn-md btn-success">+ Add New Company</a>
|
||||||
|
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-md btn-secondary">← Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Companies Table -->
|
<!-- Companies Table -->
|
||||||
@@ -57,7 +60,7 @@
|
|||||||
<td>{{ company.created_at.strftime('%Y-%m-%d') }}</td>
|
<td>{{ company.created_at.strftime('%Y-%m-%d') }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<a href="{{ url_for('system_admin_company_detail', company_id=company.id) }}"
|
<a href="{{ url_for('system_admin.system_admin_company_detail', company_id=company.id) }}"
|
||||||
class="btn btn-sm btn-primary">View Details</a>
|
class="btn btn-sm btn-primary">View Details</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -72,13 +75,13 @@
|
|||||||
<div class="pagination-section">
|
<div class="pagination-section">
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{% if companies.has_prev %}
|
{% if companies.has_prev %}
|
||||||
<a href="{{ url_for('system_admin_companies', page=companies.prev_num) }}" class="page-link">← Previous</a>
|
<a href="{{ url_for('system_admin.system_admin_companies', page=companies.prev_num) }}" class="page-link">← Previous</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for page_num in companies.iter_pages() %}
|
{% for page_num in companies.iter_pages() %}
|
||||||
{% if page_num %}
|
{% if page_num %}
|
||||||
{% if page_num != companies.page %}
|
{% if page_num != companies.page %}
|
||||||
<a href="{{ url_for('system_admin_companies', page=page_num) }}" class="page-link">{{ page_num }}</a>
|
<a href="{{ url_for('system_admin.system_admin_companies', page=page_num) }}" class="page-link">{{ page_num }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="page-link current">{{ page_num }}</span>
|
<span class="page-link current">{{ page_num }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -88,7 +91,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if companies.has_next %}
|
{% if companies.has_next %}
|
||||||
<a href="{{ url_for('system_admin_companies', page=companies.next_num) }}" class="page-link">Next →</a>
|
<a href="{{ url_for('system_admin.system_admin_companies', page=companies.next_num) }}" class="page-link">Next →</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user