From 9a79778ad600dc64dd2890f1ef291bccc0d6f524 Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Mon, 7 Jul 2025 21:16:36 +0200 Subject: [PATCH] Squashed commit of the following: commit 1eeea9f83ad9230a5c1f7a75662770eaab0df837 Author: Jens Luedicke Date: Mon Jul 7 21:15:41 2025 +0200 Disable resuming of old time entries. commit 3e3ec2f01cb7943622b819a19179388078ae1315 Author: Jens Luedicke Date: Mon Jul 7 20:59:19 2025 +0200 Refactor db migrations. commit 15a51a569da36c6b7c9e01ab17b6fdbdee6ad994 Author: Jens Luedicke Date: Mon Jul 7 19:58:04 2025 +0200 Apply new style for Time Tracking view. commit 77e5278b303e060d2b03853b06277f8aa567ae68 Author: Jens Luedicke Date: Mon Jul 7 18:06:04 2025 +0200 Allow direct registrations as a Company. commit 188a8772757cbef374243d3a5f29e4440ddecabe Author: Jens Luedicke Date: Mon Jul 7 18:04:45 2025 +0200 Add email invitation feature. commit d9ebaa02aa01b518960a20dccdd5a327d82f30c6 Author: Jens Luedicke Date: Mon Jul 7 17:12:32 2025 +0200 Apply common style for Company, User, Team management pages. commit 81149caf4d8fc6317e2ab1b4f022b32fc5aa6d22 Author: Jens Luedicke Date: Mon Jul 7 16:44:32 2025 +0200 Move export functions to own module. commit 1a26e19338e73f8849c671471dd15cc3c1b1fe82 Author: Jens Luedicke Date: Mon Jul 7 15:51:15 2025 +0200 Split up models.py. commit 61f1ccd10f721b0ff4dc1eccf30c7a1ee13f204d Author: Jens Luedicke Date: Mon Jul 7 12:05:28 2025 +0200 Move utility function into own modules. commit 84b341ed35e2c5387819a8b9f9d41eca900ae79f Author: Jens Luedicke Date: Mon Jul 7 11:44:24 2025 +0200 Refactor auth functions use. commit 923e311e3da5b26d85845c2832b73b7b17c48adb Author: Jens Luedicke Date: Mon Jul 7 11:35:52 2025 +0200 Refactor route nameing and fix bugs along the way. commit f0a5c4419c340e62a2615c60b2a9de28204d2995 Author: Jens Luedicke Date: Mon Jul 7 10:34:33 2025 +0200 Fix URL endpoints in announcement template. commit b74d74542a1c8dc350749e4788a9464d067a88b5 Author: Jens Luedicke Date: Mon Jul 7 09:25:53 2025 +0200 Move announcements to own module. commit 9563a28021ac46c82c04fe4649b394dbf96f92c7 Author: Jens Luedicke Date: Mon Jul 7 09:16:30 2025 +0200 Combine Company view and edit templates. commit 6687c373e681d54e4deab6b2582fed5cea9aadf6 Author: Jens Luedicke Date: Mon Jul 7 08:17:42 2025 +0200 Move Users, Company and System Administration to own modules. commit 8b7894a2e3eb84bb059f546648b6b9536fea724e Author: Jens Luedicke Date: Mon Jul 7 07:40:57 2025 +0200 Move Teams and Projects to own modules. commit d11bf059d99839ecf1f5d7020b8c8c8a2454c00b Author: Jens Luedicke Date: Mon Jul 7 07:09:33 2025 +0200 Move Tasks and Sprints to own modules. --- Dockerfile | 8 +- SCHEMA_CHANGES_SUMMARY.md | 176 + app.py | 4205 ++--------------- data_formatting.py | 2 +- migrations/migration_list.txt | 24 + .../old_migrations/00_migration_summary.py | 79 + .../old_migrations/01_migrate_db.py | 3 + .../02_migrate_sqlite_to_postgres.py | 12 + .../02_migrate_sqlite_to_postgres_fixed.py | 361 ++ .../03_add_dashboard_columns.py | 104 + .../04_add_user_preferences_columns.py | 159 + .../old_migrations/05_fix_task_status_enum.py | 244 + .../old_migrations/06_add_archived_status.py | 77 + .../07_fix_company_work_config_columns.py | 141 + .../old_migrations/08_fix_work_region_enum.py | 145 + .../09_add_germany_to_workregion.py | 78 + .../10_add_company_settings_columns.py | 108 + .../11_fix_company_work_config_usage.py | 188 + .../12_fix_task_status_usage.py | 172 + .../13_fix_work_region_usage.py | 154 + .../old_migrations/14_fix_removed_fields.py | 227 + .../old_migrations/15_repair_user_roles.py | 26 +- .../19_add_company_invitations.py | 65 + .../20_add_company_updated_at.py | 94 + .../old_migrations/run_all_db_migrations.py | 138 + .../old_migrations/run_code_migrations.py | 166 + migrations/postgres_only_migration.py | 327 ++ migrations/run_postgres_migrations.py | 131 + models/__init__.py | 49 + models/announcement.py | 91 + models/base.py | 20 + models/company.py | 232 + models/dashboard.py | 99 + models/enums.py | 115 + models/invitation.py | 56 + models/project.py | 86 + models/sprint.py | 117 + models/system.py | 132 + models/task.py | 245 + models/team.py | 26 + models/time_entry.py | 32 + models/user.py | 203 + models/work_config.py | 31 + models.py => models_old.py | 0 routes/announcements.py | 189 + routes/auth.py | 100 + routes/company.py | 360 ++ routes/company_api.py | 102 + routes/export.py | 94 + routes/export_api.py | 96 + routes/invitations.py | 217 + routes/projects.py | 238 + routes/projects_api.py | 170 + routes/sprints.py | 47 + routes/sprints_api.py | 224 + routes/system_admin.py | 511 ++ routes/tasks.py | 128 + routes/tasks_api.py | 985 ++++ routes/teams.py | 191 + routes/teams_api.py | 124 + routes/users.py | 532 +++ routes/users_api.py | 75 + startup.sh | 64 +- startup_postgres.sh | 40 + static/js/subtasks.js | 4 +- static/js/time-tracking.js | 445 ++ templates/admin_company.html | 1002 +++- templates/admin_projects.html | 1542 ++++-- templates/admin_teams.html | 626 ++- templates/admin_users.html | 1136 ++++- templates/admin_work_policies.html | 26 +- templates/analytics.html | 18 +- templates/company_users.html | 20 +- templates/config.html | 6 +- templates/confirm_company_deletion.html | 4 +- templates/create_project.html | 4 +- templates/create_team.html | 24 - templates/create_user.html | 4 +- templates/dashboard.html | 13 + templates/edit_company.html | 73 - templates/edit_project.html | 65 +- templates/edit_user.html | 4 +- templates/emails/invitation.html | 152 + templates/emails/invitation_reminder.html | 108 + templates/export.html | 18 +- templates/index.html | 1634 +++++-- templates/index_old.html | 938 ++++ templates/invitations/list.html | 473 ++ templates/invitations/send.html | 378 ++ templates/layout.html | 23 +- templates/manage_project_tasks.html | 2 +- templates/manage_team.html | 96 - templates/profile.html | 1251 +++-- templates/register.html | 217 +- templates/register_invitation.html | 172 + templates/setup_company.html | 2 +- templates/system_admin_announcement_form.html | 4 +- templates/system_admin_announcements.html | 14 +- templates/system_admin_branding.html | 4 +- templates/system_admin_companies.html | 13 +- templates/system_admin_company_detail.html | 65 +- templates/system_admin_dashboard.html | 24 +- templates/system_admin_edit_user.html | 36 +- templates/system_admin_health.html | 2 +- templates/system_admin_settings.html | 12 +- templates/system_admin_time_entries.html | 11 +- templates/system_admin_users.html | 34 +- templates/task_modal.html | 7 +- templates/team_form.html | 694 +++ templates/time_tracking.html | 1235 +++++ templates/unified_task_management.html | 68 +- utils/__init__.py | 1 + utils/auth.py | 18 + utils/repository.py | 197 + utils/settings.py | 11 + utils/validation.py | 151 + 116 files changed, 21063 insertions(+), 5653 deletions(-) create mode 100644 SCHEMA_CHANGES_SUMMARY.md create mode 100644 migrations/migration_list.txt create mode 100755 migrations/old_migrations/00_migration_summary.py rename migrate_db.py => migrations/old_migrations/01_migrate_db.py (99%) rename migrate_sqlite_to_postgres.py => migrations/old_migrations/02_migrate_sqlite_to_postgres.py (95%) create mode 100644 migrations/old_migrations/02_migrate_sqlite_to_postgres_fixed.py create mode 100644 migrations/old_migrations/03_add_dashboard_columns.py create mode 100755 migrations/old_migrations/04_add_user_preferences_columns.py create mode 100755 migrations/old_migrations/05_fix_task_status_enum.py create mode 100755 migrations/old_migrations/06_add_archived_status.py create mode 100755 migrations/old_migrations/07_fix_company_work_config_columns.py create mode 100755 migrations/old_migrations/08_fix_work_region_enum.py create mode 100755 migrations/old_migrations/09_add_germany_to_workregion.py create mode 100755 migrations/old_migrations/10_add_company_settings_columns.py create mode 100755 migrations/old_migrations/11_fix_company_work_config_usage.py create mode 100755 migrations/old_migrations/12_fix_task_status_usage.py create mode 100755 migrations/old_migrations/13_fix_work_region_usage.py create mode 100755 migrations/old_migrations/14_fix_removed_fields.py rename repair_roles.py => migrations/old_migrations/15_repair_user_roles.py (70%) create mode 100644 migrations/old_migrations/19_add_company_invitations.py create mode 100755 migrations/old_migrations/20_add_company_updated_at.py create mode 100755 migrations/old_migrations/run_all_db_migrations.py create mode 100755 migrations/old_migrations/run_code_migrations.py create mode 100755 migrations/postgres_only_migration.py create mode 100755 migrations/run_postgres_migrations.py create mode 100644 models/__init__.py create mode 100644 models/announcement.py create mode 100644 models/base.py create mode 100644 models/company.py create mode 100644 models/dashboard.py create mode 100644 models/enums.py create mode 100644 models/invitation.py create mode 100644 models/project.py create mode 100644 models/sprint.py create mode 100644 models/system.py create mode 100644 models/task.py create mode 100644 models/team.py create mode 100644 models/time_entry.py create mode 100644 models/user.py create mode 100644 models/work_config.py rename models.py => models_old.py (100%) create mode 100644 routes/announcements.py create mode 100644 routes/auth.py create mode 100644 routes/company.py create mode 100644 routes/company_api.py create mode 100644 routes/export.py create mode 100644 routes/export_api.py create mode 100644 routes/invitations.py create mode 100644 routes/projects.py create mode 100644 routes/projects_api.py create mode 100644 routes/sprints.py create mode 100644 routes/sprints_api.py create mode 100644 routes/system_admin.py create mode 100644 routes/tasks.py create mode 100644 routes/tasks_api.py create mode 100644 routes/teams.py create mode 100644 routes/teams_api.py create mode 100644 routes/users.py create mode 100644 routes/users_api.py create mode 100755 startup_postgres.sh create mode 100644 static/js/time-tracking.js delete mode 100644 templates/create_team.html delete mode 100644 templates/edit_company.html create mode 100644 templates/emails/invitation.html create mode 100644 templates/emails/invitation_reminder.html create mode 100644 templates/index_old.html create mode 100644 templates/invitations/list.html create mode 100644 templates/invitations/send.html delete mode 100644 templates/manage_team.html create mode 100644 templates/register_invitation.html create mode 100644 templates/team_form.html create mode 100644 templates/time_tracking.html create mode 100644 utils/__init__.py create mode 100644 utils/auth.py create mode 100644 utils/repository.py create mode 100644 utils/settings.py create mode 100644 utils/validation.py diff --git a/Dockerfile b/Dockerfile index 3a1427f..414da72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,11 +44,11 @@ RUN mkdir -p /app/static/uploads/avatars && chmod -R 777 /app/static/uploads VOLUME /data RUN mkdir /data && chmod 777 /data -# Make startup script executable -RUN chmod +x startup.sh +# Make startup scripts executable +RUN chmod +x startup.sh startup_postgres.sh || true # Expose the port the app runs on (though we'll use unix socket) EXPOSE 5000 -# Use startup script for automatic migration -CMD ["./startup.sh"] \ No newline at end of file +# Use PostgreSQL-only startup script +CMD ["./startup_postgres.sh"] \ No newline at end of file diff --git a/SCHEMA_CHANGES_SUMMARY.md b/SCHEMA_CHANGES_SUMMARY.md new file mode 100644 index 0000000..6793597 --- /dev/null +++ b/SCHEMA_CHANGES_SUMMARY.md @@ -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 \ No newline at end of file diff --git a/app.py b/app.py index cbb5013..6cafd4e 100644 --- a/app.py +++ b/app.py @@ -1,13 +1,10 @@ from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file, abort -from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, CompanySettings, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility, BrandingSettings +from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, CompanySettings, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility, BrandingSettings, CompanyInvitation from data_formatting import ( format_duration, prepare_export_data, prepare_team_hours_export_data, format_table_data, format_graph_data, format_team_data, format_burndown_data ) -from data_export import ( - export_to_csv, export_to_excel, export_team_hours_to_csv, export_team_hours_to_excel, - export_analytics_csv, export_analytics_excel -) +# Data export functions moved to routes/export.py and routes/export_api.py from time_utils import apply_time_rounding, round_duration_to_interval, get_user_rounding_settings import logging from datetime import datetime, time, timedelta @@ -22,6 +19,37 @@ from dotenv import load_dotenv from password_utils import PasswordValidator from werkzeug.security import check_password_hash +# Import blueprints +# from routes.notes import notes_bp +# from routes.notes_download import notes_download_bp +# from routes.notes_api import notes_api_bp +from routes.tasks import tasks_bp, get_filtered_tasks_for_burndown +from routes.tasks_api import tasks_api_bp +from routes.sprints import sprints_bp +from routes.sprints_api import sprints_api_bp +from routes.teams import teams_bp +from routes.teams_api import teams_api_bp +from routes.projects import projects_bp +from routes.projects_api import projects_api_bp +from routes.company import companies_bp, setup_company as company_setup +from routes.company_api import company_api_bp +from routes.users import users_bp +from routes.users_api import users_api_bp +from routes.system_admin import system_admin_bp +from routes.announcements import announcements_bp +from routes.export import export_bp +from routes.export_api import export_api_bp + +# Import auth decorators from routes.auth +from routes.auth import login_required, admin_required, system_admin_required, role_required, company_required + +# Import utility functions +from utils.auth import is_system_admin, can_access_system_settings +from utils.settings import get_system_setting + +# Import analytics data function from export module +from routes.export_api import get_filtered_analytics_data + # Load environment variables from .env file load_dotenv() @@ -55,61 +83,32 @@ mail = Mail(app) # Initialize the database with the app db.init_app(app) -# Consolidated migration using migrate_db module -def run_migrations(): - """Run all database migrations using the consolidated migrate_db module.""" - # Check if we're using PostgreSQL or SQLite - database_url = app.config['SQLALCHEMY_DATABASE_URI'] - print(f"DEBUG: Database URL: {database_url}") +# Register blueprints +# app.register_blueprint(notes_bp) +# app.register_blueprint(notes_download_bp) +# app.register_blueprint(notes_api_bp) +app.register_blueprint(tasks_bp) +app.register_blueprint(tasks_api_bp) +app.register_blueprint(sprints_bp) +app.register_blueprint(sprints_api_bp) +app.register_blueprint(teams_bp) +app.register_blueprint(teams_api_bp) +app.register_blueprint(projects_bp) +app.register_blueprint(projects_api_bp) +app.register_blueprint(companies_bp) +app.register_blueprint(company_api_bp) +app.register_blueprint(users_bp) +app.register_blueprint(users_api_bp) +app.register_blueprint(system_admin_bp) +app.register_blueprint(announcements_bp) +app.register_blueprint(export_bp) +app.register_blueprint(export_api_bp) - is_postgresql = 'postgresql://' in database_url or 'postgres://' in database_url - print(f"DEBUG: Is PostgreSQL: {is_postgresql}") +# Import and register invitations blueprint +from routes.invitations import invitations_bp +app.register_blueprint(invitations_bp) - if is_postgresql: - print("Using PostgreSQL - skipping SQLite migrations, ensuring tables exist...") - with app.app_context(): - db.create_all() - init_system_settings() - - # Run PostgreSQL-specific migrations - try: - from migrate_db import migrate_postgresql_schema - migrate_postgresql_schema() - except ImportError: - print("PostgreSQL migration function not available") - except Exception as e: - print(f"Warning: PostgreSQL migration failed: {e}") - print("PostgreSQL setup completed successfully!") - else: - print("Using SQLite - running SQLite migrations...") - try: - from migrate_db import run_all_migrations - run_all_migrations() - print("SQLite database migrations completed successfully!") - except ImportError as e: - print(f"Error importing migrate_db: {e}") - print("Falling back to basic table creation...") - with app.app_context(): - db.create_all() - init_system_settings() - except Exception as e: - print(f"Error during SQLite migration: {e}") - print("Falling back to basic table creation...") - with app.app_context(): - db.create_all() - init_system_settings() - -def migrate_to_company_model(): - """Migrate existing data to support company model (stub - handled by migrate_db)""" - try: - from migrate_db import migrate_to_company_model, get_db_path - db_path = get_db_path() - migrate_to_company_model(db_path) - except ImportError: - print("migrate_db module not available - skipping company model migration") - except Exception as e: - print(f"Error during company migration: {e}") - raise +# Migration functions removed - migrations are now handled by startup.sh def init_system_settings(): """Initialize system settings with default values if they don't exist""" @@ -121,7 +120,6 @@ def init_system_settings(): description='Controls whether new user registration is allowed' ) db.session.add(reg_setting) - db.session.commit() if not SystemSettings.query.filter_by(key='email_verification_required').first(): print("Adding email_verification_required system setting...") @@ -131,47 +129,15 @@ def init_system_settings(): description='Controls whether email verification is required for new user accounts' ) db.session.add(email_setting) - db.session.commit() -def migrate_data(): - """Handle data migrations and setup (stub - handled by migrate_db)""" - try: - from migrate_db import migrate_data - migrate_data() - except ImportError: - print("migrate_db module not available - skipping data migration") - except Exception as e: - print(f"Error during data migration: {e}") - raise - -def migrate_work_config_data(): - """Migrate existing WorkConfig data to new architecture (stub - handled by migrate_db)""" - try: - from migrate_db import migrate_work_config_data, get_db_path - db_path = get_db_path() - migrate_work_config_data(db_path) - except ImportError: - print("migrate_db module not available - skipping work config data migration") - except Exception as e: - print(f"Error during work config migration: {e}") - raise - -def migrate_task_system(): - """Create tables for the task management system (stub - handled by migrate_db)""" - try: - from migrate_db import migrate_task_system, get_db_path - db_path = get_db_path() - migrate_task_system(db_path) - except ImportError: - print("migrate_db module not available - skipping task system migration") - except Exception as e: - print(f"Error during task system migration: {e}") - raise +# Data migration functions removed - migrations are now handled by startup.sh # Call this function during app initialization @app.before_first_request def initialize_app(): - run_migrations() # This handles all migrations including work config data + # Initialize system settings only + with app.app_context(): + init_system_settings() # Add this after initializing the app but before defining routes @app.context_processor @@ -180,7 +146,13 @@ def inject_globals(): # Get active announcements for current user active_announcements = [] if g.user: - active_announcements = Announcement.get_active_announcements_for_user(g.user) + try: + active_announcements = Announcement.get_active_announcements_for_user(g.user) + except Exception as e: + # If there's a database error, rollback and continue + db.session.rollback() + logger.error(f"Error fetching announcements: {e}") + active_announcements = [] # Get tracking script settings tracking_script_enabled = False @@ -194,12 +166,18 @@ def inject_globals(): tracking_code_setting = SystemSettings.query.filter_by(key='tracking_script_code').first() if tracking_code_setting: tracking_script_code = tracking_code_setting.value + except Exception as e: + # Rollback on any database error + db.session.rollback() + logger.error(f"Error fetching system settings: {e}") except Exception: pass # In case database isn't available yet return { 'Role': Role, 'AccountType': AccountType, 'current_year': datetime.now().year, + 'today': datetime.now().date(), + 'now': datetime.now, 'active_announcements': active_announcements, 'tracking_script_enabled': tracking_script_enabled, 'tracking_script_code': tracking_script_code @@ -267,120 +245,8 @@ def format_duration_filter(duration_seconds): return format_duration_readable(duration_seconds) # Authentication decorator -def login_required(f): - @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 - -# Admin-only decorator -def admin_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if g.user is None or (g.user.role != Role.ADMIN and g.user.role != Role.SYSTEM_ADMIN): - flash('You need administrator privileges to access this page.', 'error') - return redirect(url_for('home')) - return f(*args, **kwargs) - return decorated_function - -# System Admin-only decorator -def system_admin_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if g.user is None or g.user.role != Role.SYSTEM_ADMIN: - flash('You need system administrator privileges to access this page.', 'error') - return redirect(url_for('home')) - return f(*args, **kwargs) - return decorated_function - -def get_system_setting(key, default='false'): - """Helper function to get system setting value""" - setting = SystemSettings.query.filter_by(key=key).first() - return setting.value if setting else default - -def is_system_admin(user=None): - """Helper function to check if user is system admin""" - if user is None: - user = g.user - return user and user.role == Role.SYSTEM_ADMIN - -def can_access_system_settings(user=None): - """Helper function to check if user can access system-wide settings""" - return is_system_admin(user) - -def get_available_roles(): - """Get roles available for assignment, excluding SYSTEM_ADMIN unless one already exists""" - roles = list(Role) - - # Only show SYSTEM_ADMIN role if at least one system admin already exists - # This prevents accidental creation of system admins - system_admin_exists = User.query.filter_by(role=Role.SYSTEM_ADMIN).count() > 0 - - if not system_admin_exists: - roles = [role for role in roles if role != Role.SYSTEM_ADMIN] - - return roles - -# Add this decorator function after your existing decorators -def role_required(min_role): - """ - Decorator to restrict access based on user role. - min_role should be a Role enum value (e.g., Role.TEAM_LEADER) - """ - def role_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) - - # Check role hierarchy - role_hierarchy = { - Role.TEAM_MEMBER: 1, - Role.TEAM_LEADER: 2, - Role.SUPERVISOR: 3, - Role.ADMIN: 4, - Role.SYSTEM_ADMIN: 5 - } - - if role_hierarchy.get(g.user.role, 0) < role_hierarchy.get(min_role, 0): - 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 role_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 +# Auth decorators have been moved to routes.auth +# Utility functions have been moved to utils modules @app.before_request def load_logged_in_user(): @@ -403,7 +269,7 @@ def load_logged_in_user(): g.show_email_verification_nag = True else: g.show_email_verification_nag = False - + # Check if user has no email at all if g.user and not g.user.email: g.show_email_nag = True @@ -411,43 +277,91 @@ def load_logged_in_user(): g.show_email_nag = False else: g.company = None - + # Load branding settings g.branding = BrandingSettings.get_current() +@app.route('/setup', methods=['GET', 'POST']) +def setup(): + """Company setup route - delegates to imported function""" + return company_setup() + @app.route('/') def home(): if g.user: - # Get active entry (no departure time) + # Get active time entry active_entry = TimeEntry.query.filter_by( user_id=g.user.id, departure_time=None ).first() - # Get today's completed entries for history - today = datetime.now().date() - history = TimeEntry.query.filter( - TimeEntry.user_id == g.user.id, - TimeEntry.departure_time.isnot(None), - TimeEntry.arrival_time >= datetime.combine(today, time.min), - TimeEntry.arrival_time <= datetime.combine(today, time.max) - ).order_by(TimeEntry.arrival_time.desc()).all() + # Get recent time entries (limit to 50 like the time_tracking route) + history = TimeEntry.query.filter_by(user_id=g.user.id).order_by( + TimeEntry.arrival_time.desc() + ).limit(50).all() - # Get available projects for this user (company-scoped) + # Get available projects available_projects = [] if g.user.company_id: - all_projects = Project.query.filter_by( - company_id=g.user.company_id, - is_active=True - ).all() - for project in all_projects: - if project.is_user_allowed(g.user): - available_projects.append(project) + if g.user.role == Role.ADMIN: + # Admin can see all company projects + available_projects = Project.query.filter_by( + company_id=g.user.company_id, + is_active=True + ).order_by(Project.code).all() + else: + # Regular users see only their assigned projects + all_projects = Project.query.filter_by( + company_id=g.user.company_id, + is_active=True + ).all() + for project in all_projects: + if project.is_user_allowed(g.user): + available_projects.append(project) + + # Calculate statistics (if we want the stats section to show) + from datetime import date, timedelta + today = date.today() + week_start = today - timedelta(days=today.weekday()) + month_start = today.replace(day=1) + + # Today's hours + today_entries = TimeEntry.query.filter( + TimeEntry.user_id == g.user.id, + func.date(TimeEntry.arrival_time) == today + ).all() + today_hours = sum(entry.duration or 0 for entry in today_entries) + + # This week's hours + week_entries = TimeEntry.query.filter( + TimeEntry.user_id == g.user.id, + func.date(TimeEntry.arrival_time) >= week_start + ).all() + week_hours = sum(entry.duration or 0 for entry in week_entries) + + # This month's hours + month_entries = TimeEntry.query.filter( + TimeEntry.user_id == g.user.id, + func.date(TimeEntry.arrival_time) >= month_start + ).all() + month_hours = sum(entry.duration or 0 for entry in month_entries) + + # Active projects (projects with recent entries) + active_project_ids = db.session.query(TimeEntry.project_id).filter( + TimeEntry.user_id == g.user.id, + TimeEntry.project_id.isnot(None), + TimeEntry.arrival_time >= datetime.now() - timedelta(days=30) + ).distinct().all() + active_projects = [p for p in available_projects if p.id in [pid[0] for pid in active_project_ids]] return render_template('index.html', title='Home', active_entry=active_entry, history=history, - available_projects=available_projects) + available_projects=available_projects, + today_hours=today_hours, + week_hours=week_hours, + month_hours=month_hours, + active_projects=active_projects) else: return render_template('index.html', title='Home') @@ -558,10 +472,7 @@ def register(): flash('Registration is currently disabled by the administrator.', 'error') return redirect(url_for('login')) - # Check if companies exist, if not redirect to company setup - if Company.query.count() == 0: - flash('No companies exist yet. Please set up your company first.', 'info') - return redirect(url_for('setup_company')) + # No longer redirect to company setup - users can now create companies during registration if request.method == 'POST': username = request.form.get('username') @@ -569,6 +480,7 @@ def register(): password = request.form.get('password') confirm_password = request.form.get('confirm_password') company_code = request.form.get('company_code', '').strip() + registration_type = request.form.get('registration_type', 'company') # Validate input error = None @@ -578,9 +490,7 @@ def register(): error = 'Password is required' elif password != confirm_password: error = 'Passwords do not match' - elif not company_code: - error = 'Company code is required' - + # Validate password strength if not error: validator = PasswordValidator() @@ -588,12 +498,56 @@ def register(): if not is_valid: error = password_errors[0] # Show first error - # Find company by code + # Find company by code or create new one if no code provided company = None if company_code: + # User provided a code - try to join existing company company = Company.query.filter_by(slug=company_code.lower()).first() if not company: error = 'Invalid company code' + else: + # No code provided - create a new company (only for company registration type) + if registration_type == 'company' and not error: + # Generate company name from username + company_name = f"{username}'s Company" + company_slug = f"{username.lower()}-company" + + # Ensure unique slug + base_slug = company_slug + counter = 1 + while Company.query.filter_by(slug=company_slug).first(): + company_slug = f"{base_slug}-{counter}" + counter += 1 + + # Create new company + company = Company( + name=company_name, + slug=company_slug, + max_users=None, # Unlimited users + is_personal=False # This is a regular company, not a freelancer workspace + ) + db.session.add(company) + db.session.flush() # Get the company ID without committing + + # Create default work configuration for the company + preset = CompanyWorkConfig.get_regional_preset(WorkRegion.GERMANY) + work_config = CompanyWorkConfig( + company_id=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) # Check for existing users within the company if company and not error: @@ -697,7 +651,7 @@ def register_freelancer(): error = 'Password is required' elif password != confirm_password: error = 'Passwords do not match' - + # Validate password strength if not error: validator = PasswordValidator() @@ -771,125 +725,90 @@ def register_freelancer(): return render_template('register_freelancer.html', title='Register as Freelancer') -@app.route('/setup_company', methods=['GET', 'POST']) -def setup_company(): - """Company setup route for creating new companies with admin users""" - existing_companies = Company.query.count() +@app.route('/register/invitation/', methods=['GET', 'POST']) +def register_with_invitation(token): + """Registration route for users with an invitation""" + # Find the invitation + invitation = CompanyInvitation.query.filter_by(token=token).first() - # Determine access level - is_initial_setup = existing_companies == 0 - is_super_admin = g.user and g.user.role == Role.ADMIN and existing_companies > 0 - is_authorized = is_initial_setup or is_super_admin + if not invitation: + flash('Invalid invitation link', 'error') + return redirect(url_for('register')) - # Check authorization for non-initial setups - if not is_initial_setup and not is_super_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 not invitation.is_valid(): + if invitation.accepted: + flash('This invitation has already been used', 'error') + else: + flash('This invitation has expired', 'error') + return redirect(url_for('register')) 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') + username = request.form.get('username') + password = request.form.get('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: + if not username: + error = 'Username is required' + elif not password: + error = 'Password is required' + elif password != confirm_password: error = 'Passwords do not match' - elif len(admin_password) < 6: - error = 'Password must be at least 6 characters long' + + # Validate password strength + if not error: + validator = PasswordValidator() + is_valid, password_errors = validator.validate(password) + if not is_valid: + error = password_errors[0] + + # Check if username already exists in the company + if not error: + if User.query.filter_by(username=username, company_id=invitation.company_id).first(): + error = 'Username already exists in this company' if error is None: try: - # Generate company slug - import re - 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 + # Create the user + new_user = User( + username=username, + email=invitation.email, + company_id=invitation.company_id, + role=Role[invitation.role.upper().replace(' ', '_')], + is_verified=True # Pre-verified through invitation ) - db.session.add(company) - db.session.flush() # Get company.id without committing + new_user.set_password(password) - # 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() + db.session.add(new_user) - if existing_user_by_username: - error = 'Username already exists in this company' - elif existing_user_by_email: - error = 'Email already registered in this company' + # Mark invitation as accepted + invitation.accepted = True + invitation.accepted_at = datetime.now() + invitation.accepted_by_user_id = new_user.id - 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() + 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 + logger.info(f"User {username} created through invitation for company {invitation.company.name}") + flash(f'Welcome to {invitation.company.name}! Your account has been created. Please log in.', 'success') - 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('admin_company') if g.user else url_for('login')) - else: - db.session.rollback() + return redirect(url_for('login')) 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)}" + logger.error(f"Error during invitation registration: {str(e)}") + error = 'An error occurred during registration. Please try again.' 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_super_admin) + # GET request - show the registration form + return render_template('register_invitation.html', + invitation=invitation, + title='Accept Invitation') +# Setup company route is now imported from company module +app.add_url_rule('/setup_company', 'setup_company', company_setup, methods=['GET', 'POST']) @app.route('/verify_email/') def verify_email(token): user = User.query.filter_by(verification_token=token).first() @@ -913,371 +832,6 @@ def dashboard(): """User dashboard with configurable widgets.""" return render_template('dashboard.html', title='Dashboard') -# Redirect old admin dashboard URL to new dashboard - -@app.route('/admin/users') -@admin_required -@company_required -def admin_users(): - users = User.query.filter_by(company_id=g.user.company_id).all() - return render_template('admin_users.html', title='User Management', users=users) - -@app.route('/admin/users/create', methods=['GET', 'POST']) -@admin_required -@company_required -def create_user(): - if request.method == 'POST': - 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 input - error = None - if not username: - error = 'Username is required' - elif not email: - error = 'Email is required' - elif not password: - error = 'Password is required' - elif User.query.filter_by(username=username, company_id=g.user.company_id).first(): - error = 'Username already exists in your company' - elif User.query.filter_by(email=email, company_id=g.user.company_id).first(): - error = 'Email already registered in your company' - - if error is None: - # 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( - 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) - msg = Message(f'Verify your {g.branding.app_name} account', recipients=[email]) - msg.body = f'''Hello {username}, - -An administrator has created an account for you on {g.branding.app_name}. To activate your account, please click on the link below: - -{verification_url} - -This link will expire in 24 hours. - -Best regards, -The {g.branding.app_name} Team -''' - mail.send(msg) - - db.session.add(new_user) - db.session.commit() - - if auto_verify: - flash(f'User {username} created and automatically verified!', 'success') - else: - flash(f'User {username} created! Verification email sent.', 'success') - return redirect(url_for('admin_users')) - - flash(error, 'error') - - # 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) - -@app.route('/admin/users/edit/', methods=['GET', 'POST']) -@admin_required -@company_required -def edit_user(user_id): - user = User.query.filter_by(id=user_id, company_id=g.user.company_id).first_or_404() - - if request.method == 'POST': - 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 input - error = None - if not username: - error = 'Username is required' - elif not email: - error = 'Email is required' - elif username != user.username and User.query.filter_by(username=username, company_id=g.user.company_id).first(): - error = 'Username already exists in your company' - elif email != user.email and User.query.filter_by(email=email, company_id=g.user.company_id).first(): - error = 'Email already registered in your company' - if error is None: - user.username = username - user.email = email - - # Convert role string to enum - try: - user.role = Role[role_name] if role_name else Role.TEAM_MEMBER - except KeyError: - user.role = Role.TEAM_MEMBER - - user.team_id = team_id if team_id else None - - if password: - user.set_password(password) - - db.session.commit() - - flash(f'User {username} updated successfully!', 'success') - return redirect(url_for('admin_users')) - - flash(error, 'error') - - # 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) - -@app.route('/admin/users/delete/', 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('admin_users')) - - username = user.username - - try: - # Handle dependent records before deleting user - # 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 - Project.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id}) - - # Transfer ownership of tasks to alternative admin - Task.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id}) - - # Transfer ownership of subtasks to alternative admin - SubTask.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id}) - - # Transfer ownership of project categories to alternative admin - ProjectCategory.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id}) - else: - # No alternative admin found - redirect to company deletion confirmation - flash('No other administrator or supervisor found. Company deletion required.', 'warning') - return redirect(url_for('confirm_company_deletion', user_id=user_id)) - - # 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() - - flash(f'User {username} deleted successfully. Projects and tasks transferred to {alternative_admin.username}', '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('admin_users')) - -@app.route('/confirm-company-deletion/', methods=['GET', 'POST']) -@login_required -def confirm_company_deletion(user_id): - """Show confirmation page for company deletion when no alternative admin exists""" - - # Only allow admin or system admin access - if g.user.role not in [Role.ADMIN, Role.SYSTEM_ADMIN]: - flash('Access denied: Admin privileges required', 'error') - return redirect(url_for('index')) - - user = User.query.get_or_404(user_id) - - # For admin users, ensure they're in the same company - if g.user.role == Role.ADMIN and user.company_id != g.user.company_id: - flash('Access denied: You can only delete users in your company', 'error') - return redirect(url_for('admin_users')) - - # Prevent deleting yourself - if user.id == g.user.id: - flash('You cannot delete your own account', 'error') - return redirect(url_for('admin_users') if g.user.role != Role.SYSTEM_ADMIN else url_for('system_admin_users')) - - company = user.company - - # Verify no alternative admin exists - alternative_admin = User.query.filter( - User.company_id == company.id, - User.role.in_([Role.ADMIN, Role.SUPERVISOR]), - User.id != user_id - ).first() - - if alternative_admin: - flash('Alternative admin found. Regular user deletion should be used instead.', 'error') - return redirect(url_for('admin_users') if g.user.role != Role.SYSTEM_ADMIN else url_for('system_admin_users')) - - if request.method == 'POST': - # Verify company name confirmation - company_name_confirm = request.form.get('company_name_confirm', '').strip() - understand_deletion = request.form.get('understand_deletion') - - if company_name_confirm != company.name: - flash('Company name confirmation does not match', 'error') - return redirect(url_for('confirm_company_deletion', user_id=user_id)) - - if not understand_deletion: - flash('You must confirm that you understand the consequences', 'error') - return redirect(url_for('confirm_company_deletion', user_id=user_id)) - - try: - # Perform cascade deletion - company_name = company.name - - # Delete all company-related data in the correct order - # First, clear foreign key references that could cause constraint violations - - # 1. Delete time entries (they reference tasks and users) - TimeEntry.query.filter(TimeEntry.user_id.in_( - db.session.query(User.id).filter(User.company_id == company.id) - )).delete(synchronize_session=False) - - # 2. Delete user preferences and dashboards - 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) - - # 3. Delete work configs - WorkConfig.query.filter(WorkConfig.user_id.in_( - db.session.query(User.id).filter(User.company_id == company.id) - )).delete(synchronize_session=False) - - # 4. Delete subtasks (they depend on tasks) - SubTask.query.filter(SubTask.task_id.in_( - db.session.query(Task.id).filter(Task.project_id.in_( - db.session.query(Project.id).filter(Project.company_id == company.id) - )) - )).delete(synchronize_session=False) - - # 5. Delete tasks (now safe since subtasks are deleted) - Task.query.filter(Task.project_id.in_( - db.session.query(Project.id).filter(Project.company_id == company.id) - )).delete(synchronize_session=False) - - # 6. Delete projects - Project.query.filter_by(company_id=company.id).delete() - - # 7. Delete project categories - ProjectCategory.query.filter_by(company_id=company.id).delete() - - # 8. Delete company work config - CompanyWorkConfig.query.filter_by(company_id=company.id).delete() - - # 9. Delete teams - Team.query.filter_by(company_id=company.id).delete() - - # 10. Delete users - User.query.filter_by(company_id=company.id).delete() - - # 11. Delete system events for this company - SystemEvent.query.filter_by(company_id=company.id).delete() - - # 12. Finally, delete the company itself - db.session.delete(company) - - db.session.commit() - - flash(f'Company "{company_name}" and all associated data has been permanently deleted', 'success') - - # Log the deletion - SystemEvent.log_event( - event_type='company_deleted', - description=f'Company "{company_name}" was deleted by {g.user.username} due to no alternative admin for user deletion', - event_category='admin_action', - severity='warning', - user_id=g.user.id - ) - - return redirect(url_for('system_admin_companies') if g.user.role == Role.SYSTEM_ADMIN else url_for('index')) - - 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('confirm_company_deletion', user_id=user_id)) - - # GET request - show confirmation page - # Gather all data that will be deleted - users = User.query.filter_by(company_id=company.id).all() - teams = Team.query.filter_by(company_id=company.id).all() - projects = Project.query.filter_by(company_id=company.id).all() - categories = ProjectCategory.query.filter_by(company_id=company.id).all() - - # Get tasks for all projects in the company - project_ids = [p.id for p in projects] - tasks = Task.query.filter(Task.project_id.in_(project_ids)).all() if project_ids else [] - - # Count time entries - user_ids = [u.id for u in users] - time_entries_count = TimeEntry.query.filter(TimeEntry.user_id.in_(user_ids)).count() if user_ids else 0 - - # Calculate total hours - total_duration = db.session.query(func.sum(TimeEntry.duration)).filter( - TimeEntry.user_id.in_(user_ids) - ).scalar() or 0 - total_hours_tracked = round(total_duration / 3600, 2) if total_duration else 0 - - return render_template('confirm_company_deletion.html', - user=user, - company=company, - users=users, - teams=teams, - projects=projects, - categories=categories, - tasks=tasks, - time_entries_count=time_entries_count, - total_hours_tracked=total_hours_tracked) @app.route('/profile', methods=['GET', 'POST']) @login_required @@ -1332,7 +886,7 @@ def update_avatar(): """Update user avatar URL""" user = User.query.get(session['user_id']) avatar_url = request.form.get('avatar_url', '').strip() - + # Validate URL if provided if avatar_url: # Basic URL validation @@ -1344,11 +898,11 @@ def update_avatar(): r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip r'(?::\d+)?' # optional port r'(?:/?|[/?]\S+)$', re.IGNORECASE) - + if not url_pattern.match(avatar_url): flash('Please provide a valid URL for your avatar.', 'error') return redirect(url_for('profile')) - + # Additional validation for image URLs allowed_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'] if not any(avatar_url.lower().endswith(ext) for ext in allowed_extensions): @@ -1357,16 +911,16 @@ def update_avatar(): if not any(service in avatar_url.lower() for service in allowed_services): flash('Avatar URL should point to an image file (JPG, PNG, GIF, WebP, or SVG).', 'error') return redirect(url_for('profile')) - + # Update avatar URL (empty string removes custom avatar) user.avatar_url = avatar_url if avatar_url else None db.session.commit() - + if avatar_url: flash('Avatar updated successfully!', 'success') else: flash('Avatar reset to default.', 'success') - + # Log the avatar change SystemEvent.log_event( event_type='profile_avatar_updated', @@ -1375,7 +929,7 @@ def update_avatar(): user_id=user.id, company_id=user.company_id ) - + return redirect(url_for('profile')) @app.route('/upload-avatar', methods=['POST']) @@ -1385,49 +939,49 @@ def upload_avatar(): import os from werkzeug.utils import secure_filename import uuid - + user = User.query.get(session['user_id']) - + # Check if file was uploaded if 'avatar_file' not in request.files: flash('No file selected.', 'error') return redirect(url_for('profile')) - + file = request.files['avatar_file'] - + # Check if file is empty if file.filename == '': flash('No file selected.', 'error') return redirect(url_for('profile')) - + # Validate file extension allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'webp'} file_ext = file.filename.rsplit('.', 1)[1].lower() if '.' in file.filename else '' - + if file_ext not in allowed_extensions: flash('Invalid file type. Please upload a PNG, JPG, GIF, or WebP image.', 'error') return redirect(url_for('profile')) - + # Validate file size (5MB max) file.seek(0, os.SEEK_END) file_size = file.tell() file.seek(0) # Reset file pointer - + if file_size > 5 * 1024 * 1024: # 5MB flash('File size must be less than 5MB.', 'error') return redirect(url_for('profile')) - + # Generate unique filename unique_filename = f"{user.id}_{uuid.uuid4().hex}.{file_ext}" - + # Create user avatar directory if it doesn't exist avatar_dir = os.path.join(app.static_folder, 'uploads', 'avatars') os.makedirs(avatar_dir, exist_ok=True) - + # Save the file file_path = os.path.join(avatar_dir, unique_filename) file.save(file_path) - + # Delete old avatar file if it exists and is a local upload if user.avatar_url and user.avatar_url.startswith('/static/uploads/avatars/'): old_file_path = os.path.join(app.root_path, user.avatar_url.lstrip('/')) @@ -1436,13 +990,13 @@ def upload_avatar(): os.remove(old_file_path) except Exception as e: logger.warning(f"Failed to delete old avatar: {e}") - + # Update user's avatar URL user.avatar_url = f"/static/uploads/avatars/{unique_filename}" db.session.commit() - + flash('Avatar uploaded successfully!', 'success') - + # Log the avatar upload SystemEvent.log_event( event_type='profile_avatar_uploaded', @@ -1451,7 +1005,7 @@ def upload_avatar(): user_id=user.id, company_id=user.company_id ) - + return redirect(url_for('profile')) @app.route('/2fa/setup', methods=['GET', 'POST']) @@ -1588,15 +1142,15 @@ def about(): def imprint(): """Display the imprint/legal page if enabled""" branding = BrandingSettings.get_current() - + # Check if imprint is enabled if not branding or not branding.imprint_enabled: abort(404) - + title = branding.imprint_title or 'Imprint' content = branding.imprint_content or '' - - return render_template('imprint.html', + + return render_template('imprint.html', title=title, content=content) @@ -1615,8 +1169,9 @@ def timetrack(): @app.route('/api/arrive', methods=['POST']) @login_required def arrive(): - # Get project and notes from request + # Get project, task and notes from request project_id = request.json.get('project_id') if request.json else None + task_id = request.json.get('task_id') if request.json else None notes = request.json.get('notes') if request.json else None # Validate project access if project is specified @@ -1625,11 +1180,21 @@ def arrive(): if not project or not project.is_user_allowed(g.user): return jsonify({'error': 'Invalid or unauthorized project'}), 403 + # Validate task if specified + if task_id: + task = Task.query.get(task_id) + if not task: + return jsonify({'error': 'Invalid task'}), 400 + # Ensure task belongs to the specified project + if project_id and task.project_id != int(project_id): + return jsonify({'error': 'Task does not belong to the specified project'}), 400 + # Create a new time entry with arrival time for the current user new_entry = TimeEntry( user_id=g.user.id, arrival_time=datetime.now(), project_id=int(project_id) if project_id else None, + task_id=int(task_id) if task_id else None, notes=notes ) db.session.add(new_entry) @@ -1776,6 +1341,136 @@ def delete_entry(entry_id): db.session.commit() return jsonify({'success': True, 'message': 'Entry deleted successfully'}) +@app.route('/api/time-entry/', methods=['GET']) +@login_required +def get_time_entry(entry_id): + """Get details of a specific time entry""" + entry = TimeEntry.query.filter_by(id=entry_id, user_id=g.user.id).first_or_404() + + return jsonify({ + 'success': True, + 'entry': { + 'id': entry.id, + 'arrival_time': entry.arrival_time.isoformat(), + 'departure_time': entry.departure_time.isoformat() if entry.departure_time else None, + 'project_id': entry.project_id, + 'task_id': entry.task_id, + 'notes': entry.notes, + 'total_break_duration': entry.total_break_duration + } + }) + +@app.route('/time-tracking') +@login_required +@company_required +def time_tracking(): + """Modern time tracking interface""" + # Get active time entry + active_entry = TimeEntry.query.filter_by( + user_id=g.user.id, + departure_time=None + ).first() + + # Get recent time entries + history = TimeEntry.query.filter_by(user_id=g.user.id).order_by( + TimeEntry.arrival_time.desc() + ).limit(50).all() + + # Get available projects + available_projects = [] + if g.user.role == Role.ADMIN: + # Admin can see all company projects + available_projects = Project.query.filter_by( + company_id=g.user.company_id, + is_active=True + ).order_by(Project.code).all() + else: + # Regular users see only their assigned projects + all_projects = Project.query.filter_by( + company_id=g.user.company_id, + is_active=True + ).all() + for project in all_projects: + if project.is_user_allowed(g.user): + available_projects.append(project) + + # Calculate statistics + from datetime import date, timedelta + today = date.today() + week_start = today - timedelta(days=today.weekday()) + month_start = today.replace(day=1) + + # Today's hours + today_entries = TimeEntry.query.filter( + TimeEntry.user_id == g.user.id, + func.date(TimeEntry.arrival_time) == today + ).all() + today_hours = sum(entry.duration or 0 for entry in today_entries) + + # This week's hours + week_entries = TimeEntry.query.filter( + TimeEntry.user_id == g.user.id, + func.date(TimeEntry.arrival_time) >= week_start + ).all() + week_hours = sum(entry.duration or 0 for entry in week_entries) + + # This month's hours + month_entries = TimeEntry.query.filter( + TimeEntry.user_id == g.user.id, + func.date(TimeEntry.arrival_time) >= month_start + ).all() + month_hours = sum(entry.duration or 0 for entry in month_entries) + + # Active projects (projects with recent entries) + active_project_ids = db.session.query(TimeEntry.project_id).filter( + TimeEntry.user_id == g.user.id, + TimeEntry.project_id.isnot(None), + TimeEntry.arrival_time >= datetime.now() - timedelta(days=30) + ).distinct().all() + active_projects = [p for p in available_projects if p.id in [pid[0] for pid in active_project_ids]] + + return render_template('time_tracking.html', + title='Time Tracking', + active_entry=active_entry, + history=history, + available_projects=available_projects, + today_hours=today_hours, + week_hours=week_hours, + month_hours=month_hours, + active_projects=active_projects) + +@app.route('/api/projects//tasks', methods=['GET']) +@login_required +def get_project_tasks(project_id): + """Get tasks for a specific project""" + try: + project = Project.query.get(project_id) + if not project: + return jsonify({'error': 'Project not found', 'success': False}), 404 + + # Check if user has access to this project + if not project.is_user_allowed(g.user): + return jsonify({'error': 'Unauthorized access to project', 'success': False}), 403 + + # Get active tasks for the project + tasks = Task.query.filter( + Task.project_id == project_id, + Task.status != TaskStatus.ARCHIVED + ).order_by(Task.created_at.desc()).all() + + return jsonify({ + 'success': True, + 'tasks': [{ + 'id': task.id, + 'title': task.name, # Task model uses 'name' not 'title' + 'status': task.status.value, + 'priority': task.priority.value if task.priority else 'medium' + } for task in tasks] + }) + except Exception as e: + logger.error(f"Error fetching tasks for project {project_id}: {str(e)}") + return jsonify({'error': f'Server error: {str(e)}', 'success': False}), 500 + @app.route('/api/update/', methods=['PUT']) @login_required def update_entry(entry_id): @@ -1845,16 +1540,16 @@ def calculate_work_duration(arrival_time, departure_time, total_break_duration, company_config = CompanyWorkConfig.query.filter_by(company_id=user.company_id).first() if not company_config: # Use Germany defaults if no company config exists - preset = CompanyWorkConfig.get_regional_preset(WorkRegion.GERMANY) - break_threshold_hours = preset['break_threshold_hours'] - mandatory_break_minutes = preset['mandatory_break_minutes'] - additional_break_threshold_hours = preset['additional_break_threshold_hours'] - additional_break_minutes = preset['additional_break_minutes'] + preset = CompanyWorkConfig.get_regional_preset(WorkRegion.GERMANY) # Note: Germany has specific labor laws + break_threshold_hours = preset['break_after_hours'] + mandatory_break_minutes = preset['break_duration_minutes'] + # REMOVED: additional_break_threshold_hours = preset['additional_break_threshold_hours'] # This field no longer exists in the model + # REMOVED: additional_break_minutes = preset['additional_break_minutes'] # This field no longer exists in the model else: - break_threshold_hours = company_config.break_threshold_hours - mandatory_break_minutes = company_config.mandatory_break_minutes - additional_break_threshold_hours = company_config.additional_break_threshold_hours - additional_break_minutes = company_config.additional_break_minutes + break_threshold_hours = company_config.break_after_hours + mandatory_break_minutes = company_config.break_duration_minutes + # REMOVED: additional_break_threshold_hours = company_config.additional_break_threshold_hours # This field no longer exists in the model + # REMOVED: additional_break_minutes = company_config.additional_break_minutes # This field no longer exists in the model # Calculate mandatory breaks based on work duration work_hours = raw_duration / 3600 # Convert seconds to hours @@ -1865,8 +1560,8 @@ def calculate_work_duration(arrival_time, departure_time, total_break_duration, configured_break_seconds += mandatory_break_minutes * 60 # Apply additional break if work duration exceeds additional threshold - if work_hours > additional_break_threshold_hours: - configured_break_seconds += additional_break_minutes * 60 + # REMOVED: if work_hours > additional_break_threshold_hours: # This field no longer exists in the model + # REMOVED: configured_break_seconds += additional_break_minutes * 60 # This field no longer exists in the model # Use the greater of configured breaks or actual logged breaks effective_break_duration = max(configured_break_seconds, total_break_duration) @@ -1882,6 +1577,14 @@ def resume_entry(entry_id): # Find the entry to resume for the current user entry_to_resume = TimeEntry.query.filter_by(id=entry_id, user_id=session['user_id']).first_or_404() + # Check if the entry is from today + today = datetime.now().date() + if entry_to_resume.arrival_time.date() < today: + return jsonify({ + 'success': False, + 'message': 'Cannot resume entries from previous days.' + }), 400 + # Check if there's already an active entry active_entry = TimeEntry.query.filter_by(user_id=session['user_id'], departure_time=None).first() if active_entry: @@ -2021,388 +1724,7 @@ def internal_server_error(e): def test(): return "App is working!" -@app.route('/admin/users/toggle-status/') -@admin_required -@company_required -def toggle_user_status(user_id): - user = User.query.filter_by(id=user_id, company_id=g.user.company_id).first_or_404() - - # Prevent blocking yourself - if user.id == session.get('user_id'): - flash('You cannot block your own account', 'error') - return redirect(url_for('admin_users')) - - # Toggle the blocked status - user.is_blocked = not user.is_blocked - db.session.commit() - - if user.is_blocked: - flash(f'User {user.username} has been blocked', 'success') - else: - flash(f'User {user.username} has been unblocked', 'success') - - return redirect(url_for('admin_users')) - -# Add this route to manage system settings -@app.route('/admin/settings', methods=['GET', 'POST']) -@admin_required -def admin_settings(): - if request.method == 'POST': - # 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') - - # Get current 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' - - return render_template('admin_settings.html', title='System Settings', settings=settings) - -@app.route('/system-admin/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) - from datetime import datetime, timedelta - 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) - -@app.route('/system-admin/users') -@system_admin_required -def system_admin_users(): - """System Admin: View all users across all companies""" - filter_type = request.args.get('filter', '') - page = request.args.get('page', 1, type=int) - per_page = 50 - - # Build query based on filter - query = User.query - - if filter_type == 'blocked': - query = query.filter_by(is_blocked=True) - elif filter_type == 'system_admins': - query = query.filter_by(role=Role.SYSTEM_ADMIN) - elif filter_type == 'admins': - query = query.filter_by(role=Role.ADMIN) - elif filter_type == 'unverified': - query = query.filter_by(is_verified=False) - elif filter_type == 'freelancers': - query = query.filter_by(account_type=AccountType.FREELANCER) - - # Add company join for display - query = query.join(Company).add_columns(Company.name.label('company_name')) - - # Order by creation date (newest first) - query = query.order_by(User.created_at.desc()) - - # Paginate results - users = query.paginate(page=page, per_page=per_page, error_out=False) - - return render_template('system_admin_users.html', - title='System Admin - All Users', - users=users, - current_filter=filter_type) - -@app.route('/system-admin/users//edit', methods=['GET', 'POST']) -@system_admin_required -def system_admin_edit_user(user_id): - """System Admin: Edit any user across companies""" - user = User.query.get_or_404(user_id) - - if request.method == 'POST': - # Get form data - username = request.form.get('username') - email = request.form.get('email') - role = request.form.get('role') - is_blocked = request.form.get('is_blocked') == 'on' - is_verified = request.form.get('is_verified') == 'on' - company_id = request.form.get('company_id') - team_id = request.form.get('team_id') or None - - # Validation - error = None - - # Check if username is unique within the company - existing_user = User.query.filter( - User.username == username, - User.company_id == company_id, - User.id != user_id - ).first() - - if existing_user: - error = f'Username "{username}" is already taken in this company.' - - # Check if email is unique within the company - existing_email = User.query.filter( - User.email == email, - User.company_id == company_id, - User.id != user_id - ).first() - - if existing_email: - error = f'Email "{email}" is already registered in this company.' - - if not error: - # Update user - user.username = username - user.email = email - user.role = Role(role) - user.is_blocked = is_blocked - user.is_verified = is_verified - user.company_id = company_id - user.team_id = team_id - - db.session.commit() - flash(f'User {username} updated successfully.', 'success') - return redirect(url_for('system_admin_users')) - - flash(error, 'error') - - # Get all companies and teams for form dropdowns - companies = Company.query.order_by(Company.name).all() - teams = Team.query.filter_by(company_id=user.company_id).order_by(Team.name).all() - roles = get_available_roles() - - return render_template('system_admin_edit_user.html', - title=f'Edit User: {user.username}', - user=user, - companies=companies, - teams=teams, - roles=roles) - -@app.route('/system-admin/users//delete', methods=['POST']) -@system_admin_required -def system_admin_delete_user(user_id): - """System Admin: Delete any user (with safety checks)""" - user = User.query.get_or_404(user_id) - - # Safety check: 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('system_admin_users')) - - # Safety check: prevent deleting yourself - if user.id == g.user.id: - flash('Cannot delete your own account.', 'error') - return redirect(url_for('system_admin_users')) - - username = user.username - company_name = user.company.name if user.company else 'Unknown' - - try: - # Handle dependent records before deleting user - # Find an alternative admin/supervisor in the same company to transfer ownership to - 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: - # Transfer ownership of projects to alternative admin - Project.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id}) - - # Transfer ownership of tasks to alternative admin - Task.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id}) - - # Transfer ownership of subtasks to alternative admin - SubTask.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id}) - - # Transfer ownership of project categories to alternative admin - ProjectCategory.query.filter_by(created_by_id=user_id).update({'created_by_id': alternative_admin.id}) - else: - # No alternative admin found - redirect to company deletion confirmation - flash('No other administrator or supervisor found in the same company. Company deletion required.', 'warning') - return redirect(url_for('confirm_company_deletion', user_id=user_id)) - - # 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() - - flash(f'User "{username}" from company "{company_name}" has been deleted. Projects and tasks transferred to {alternative_admin.username}', '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('system_admin_users')) - -@app.route('/system-admin/companies') -@system_admin_required -def system_admin_companies(): - """System Admin: View all companies""" - page = request.args.get('page', 1, type=int) - per_page = 20 - - companies = Company.query.order_by(Company.created_at.desc()).paginate( - page=page, per_page=per_page, error_out=False) - - # Get user counts for each company - company_stats = {} - for company in companies.items: - user_count = User.query.filter_by(company_id=company.id).count() - admin_count = User.query.filter( - User.company_id == company.id, - User.role.in_([Role.ADMIN, Role.SYSTEM_ADMIN]) - ).count() - company_stats[company.id] = { - 'user_count': user_count, - 'admin_count': admin_count - } - - return render_template('system_admin_companies.html', - title='System Admin - All Companies', - companies=companies, - company_stats=company_stats) - -@app.route('/system-admin/companies/') -@system_admin_required -def system_admin_company_detail(company_id): - """System Admin: View detailed company information""" - company = Company.query.get_or_404(company_id) - - # Get company statistics - users = User.query.filter_by(company_id=company.id).all() - teams = Team.query.filter_by(company_id=company.id).all() - projects = Project.query.filter_by(company_id=company.id).all() - - # Recent activity - from datetime import datetime, timedelta - 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() - - # 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 - - return render_template('system_admin_company_detail.html', - title=f'Company: {company.name}', - company=company, - users=users, - teams=teams, - projects=projects, - recent_time_entries=recent_time_entries, - role_counts=role_counts) - -@app.route('/system-admin/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 - query = TimeEntry.query.join(User).join(Company) - - if company_filter: - query = query.filter(Company.id == company_filter) - - # Add columns for display - query = query.add_columns( - User.username, - Company.name.label('company_name'), - Project.name.label('project_name') - ).outerjoin(Project) - - # 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() - - return render_template('system_admin_time_entries.html', - title='System Admin - Time Entries', - entries=entries, - companies=companies, - current_company=company_filter) +# system_admin routes moved to system_admin blueprint @app.route('/system-admin/settings', methods=['GET', 'POST']) @system_admin_required @@ -2489,65 +1811,7 @@ def system_admin_settings(): total_users=total_users, total_system_admins=total_system_admins) -@app.route('/system-admin/branding', methods=['GET', 'POST']) -@system_admin_required -def system_admin_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(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(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) +# system_admin_branding moved to system_admin blueprint @app.route('/system-admin/health') @system_admin_required @@ -2615,841 +1879,8 @@ def system_admin_health(): uptime_duration=uptime_duration, today_events=today_events) -@app.route('/system-admin/announcements') -@system_admin_required -def system_admin_announcements(): - """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) - -@app.route('/system-admin/announcements/new', methods=['GET', 'POST']) -@system_admin_required -def system_admin_announcement_new(): - """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: - import json - target_roles = json.dumps(selected_roles) - - if selected_companies: - import json - 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('system_admin_announcements')) - - # 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) - -@app.route('/system-admin/announcements//edit', methods=['GET', 'POST']) -@system_admin_required -def system_admin_announcement_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: - import json - announcement.target_roles = json.dumps(selected_roles) - else: - announcement.target_roles = None - - if selected_companies: - import json - 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('system_admin_announcements')) - - # 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) - -@app.route('/system-admin/announcements//delete', methods=['POST']) -@system_admin_required -def system_admin_announcement_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('system_admin_announcements')) - -@app.route('/admin/work-policies', methods=['GET', 'POST']) -@admin_required -@company_required -def admin_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, - work_hours_per_day=preset['work_hours_per_day'], - mandatory_break_minutes=preset['mandatory_break_minutes'], - break_threshold_hours=preset['break_threshold_hours'], - additional_break_minutes=preset['additional_break_minutes'], - additional_break_threshold_hours=preset['additional_break_threshold_hours'], - region=WorkRegion.GERMANY, - region_name=preset['region_name'], - created_by_id=g.user.id - ) - db.session.add(work_config) - db.session.commit() - - if request.method == 'POST': - try: - # Handle regional preset selection - if request.form.get('action') == 'apply_preset': - region_code = request.form.get('region_preset') - if region_code: - region = WorkRegion(region_code) - preset = CompanyWorkConfig.get_regional_preset(region) - - work_config.work_hours_per_day = preset['work_hours_per_day'] - work_config.mandatory_break_minutes = preset['mandatory_break_minutes'] - work_config.break_threshold_hours = preset['break_threshold_hours'] - work_config.additional_break_minutes = preset['additional_break_minutes'] - work_config.additional_break_threshold_hours = preset['additional_break_threshold_hours'] - work_config.region = region - work_config.region_name = preset['region_name'] - - db.session.commit() - flash(f'Applied {preset["region_name"]} work policy preset', 'success') - return redirect(url_for('admin_work_policies')) - - # Handle manual configuration update - else: - work_config.work_hours_per_day = float(request.form.get('work_hours_per_day', 8.0)) - work_config.mandatory_break_minutes = int(request.form.get('mandatory_break_minutes', 30)) - work_config.break_threshold_hours = float(request.form.get('break_threshold_hours', 6.0)) - work_config.additional_break_minutes = int(request.form.get('additional_break_minutes', 15)) - work_config.additional_break_threshold_hours = float(request.form.get('additional_break_threshold_hours', 9.0)) - work_config.region = WorkRegion.CUSTOM - work_config.region_name = 'Custom Configuration' - - db.session.commit() - flash('Work policies updated successfully!', 'success') - return redirect(url_for('admin_work_policies')) - - except ValueError: - flash('Please enter valid numbers for all fields', 'error') - - # 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['work_hours_per_day']}h/day, {preset['mandatory_break_minutes']}min break after {preset['break_threshold_hours']}h" - }) - - return render_template('admin_work_policies.html', - title='Work Policies', - work_config=work_config, - regional_presets=regional_presets, - WorkRegion=WorkRegion) - # Company Management Routes -@app.route('/admin/company') -@admin_required -@company_required -def admin_company(): - """View and manage company settings""" - company = g.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(), - } - - return render_template('admin_company.html', title='Company Management', company=company, stats=stats) - -@app.route('/admin/company/edit', methods=['GET', 'POST']) -@admin_required -@company_required -def edit_company(): - """Edit company details""" - company = g.company - - if request.method == 'POST': - 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') - return redirect(url_for('admin_company')) - else: - flash(error, 'error') - - return render_template('edit_company.html', title='Edit Company', company=company) - -@app.route('/admin/company/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', - users=users, stats=user_stats, company=g.company) - -# Add these routes for team management -@app.route('/admin/teams') -@admin_required -@company_required -def admin_teams(): - teams = Team.query.filter_by(company_id=g.user.company_id).all() - return render_template('admin_teams.html', title='Team Management', teams=teams) - -@app.route('/admin/teams/create', methods=['GET', 'POST']) -@admin_required -@company_required -def create_team(): - if request.method == 'POST': - name = request.form.get('name') - description = request.form.get('description') - - # Validate input - error = None - if not name: - error = 'Team name is required' - elif Team.query.filter_by(name=name, company_id=g.user.company_id).first(): - error = 'Team name already exists in your company' - - if error is None: - new_team = Team(name=name, description=description, company_id=g.user.company_id) - db.session.add(new_team) - db.session.commit() - - flash(f'Team "{name}" created successfully!', 'success') - return redirect(url_for('admin_teams')) - - flash(error, 'error') - - return render_template('create_team.html', title='Create Team') - -@app.route('/admin/teams/edit/', methods=['GET', 'POST']) -@admin_required -@company_required -def edit_team(team_id): - team = Team.query.filter_by(id=team_id, company_id=g.user.company_id).first_or_404() - - if request.method == 'POST': - name = request.form.get('name') - description = request.form.get('description') - - # Validate input - error = None - if not name: - error = 'Team name is required' - elif name != team.name and Team.query.filter_by(name=name, company_id=g.user.company_id).first(): - error = 'Team name already exists in your company' - - if error is None: - team.name = name - team.description = description - db.session.commit() - - flash(f'Team "{name}" updated successfully!', 'success') - return redirect(url_for('admin_teams')) - - flash(error, 'error') - - return render_template('edit_team.html', title='Edit Team', team=team) - -@app.route('/admin/teams/delete/', methods=['POST']) -@admin_required -@company_required -def delete_team(team_id): - team = Team.query.filter_by(id=team_id, company_id=g.user.company_id).first_or_404() - - # Check if team has members - if team.users: - flash('Cannot delete team with members. Remove all members first.', 'error') - return redirect(url_for('admin_teams')) - - team_name = team.name - db.session.delete(team) - db.session.commit() - - flash(f'Team "{team_name}" deleted successfully!', 'success') - return redirect(url_for('admin_teams')) - -@app.route('/admin/teams/', methods=['GET', 'POST']) -@admin_required -@company_required -def manage_team(team_id): - team = Team.query.filter_by(id=team_id, company_id=g.user.company_id).first_or_404() - - if request.method == 'POST': - action = request.form.get('action') - - if action == 'update_team': - # Update team details - name = request.form.get('name') - description = request.form.get('description') - - # Validate input - error = None - if not name: - error = 'Team name is required' - elif name != team.name and Team.query.filter_by(name=name, company_id=g.user.company_id).first(): - error = 'Team name already exists in your company' - - if error is None: - team.name = name - team.description = description - db.session.commit() - flash(f'Team "{name}" updated successfully!', 'success') - else: - flash(error, 'error') - - 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( - 'manage_team.html', - title=f'Manage Team: {team.name}', - team=team, - team_members=team_members, - available_users=available_users - ) - -# Project Management Routes -@app.route('/admin/projects') -@role_required(Role.SUPERVISOR) # Supervisors and Admins can manage projects -@company_required -def admin_projects(): - projects = Project.query.filter_by(company_id=g.user.company_id).order_by(Project.created_at.desc()).all() - 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) - -@app.route('/admin/projects/create', methods=['GET', 'POST']) -@role_required(Role.SUPERVISOR) -@company_required -def create_project(): - if request.method == 'POST': - 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 input - error = None - if not name: - error = 'Project name is required' - elif not code: - error = 'Project code is required' - elif Project.query.filter_by(code=code, company_id=g.user.company_id).first(): - error = 'Project code already exists in your company' - - # Parse dates - start_date = None - end_date = None - if start_date_str: - try: - start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() - except ValueError: - error = 'Invalid start date format' - - if end_date_str: - try: - end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() - except ValueError: - error = 'Invalid end date format' - - if start_date and end_date and start_date > end_date: - error = 'Start date cannot be after end date' - - if error is None: - project = Project( - 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 - ) - db.session.add(project) - db.session.commit() - flash(f'Project "{name}" created successfully!', 'success') - return redirect(url_for('admin_projects')) - else: - flash(error, 'error') - - # 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) - -@app.route('/admin/projects/edit/', methods=['GET', 'POST']) -@role_required(Role.SUPERVISOR) -@company_required -def edit_project(project_id): - project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first_or_404() - - if request.method == 'POST': - 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 input - error = None - if not name: - error = 'Project name is required' - elif not code: - error = 'Project code is required' - elif code != project.code and Project.query.filter_by(code=code, company_id=g.user.company_id).first(): - error = 'Project code already exists in your company' - - # Parse dates - start_date = None - end_date = None - if start_date_str: - try: - start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() - except ValueError: - error = 'Invalid start date format' - - if end_date_str: - try: - end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() - except ValueError: - error = 'Invalid end date format' - - if start_date and end_date and start_date > end_date: - error = 'Start date cannot be after end date' - - if error is None: - project.name = name - project.description = description - project.code = code.upper() - project.team_id = int(team_id) if team_id else None - project.category_id = int(category_id) if category_id else None - project.is_active = is_active - project.start_date = start_date - project.end_date = end_date - db.session.commit() - flash(f'Project "{name}" updated successfully!', 'success') - return redirect(url_for('admin_projects')) - else: - flash(error, 'error') - - # 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) - -@app.route('/admin/projects/delete/', methods=['POST']) -@role_required(Role.ADMIN) # Only admins can delete projects -@company_required -def delete_project(project_id): - project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first_or_404() - - # Check if there are time entries associated with this project - time_entries_count = TimeEntry.query.filter_by(project_id=project_id).count() - - if time_entries_count > 0: - flash(f'Cannot delete project "{project.name}" - it has {time_entries_count} time entries associated with it. Deactivate the project instead.', 'error') - else: - project_name = project.name - db.session.delete(project) - db.session.commit() - flash(f'Project "{project_name}" deleted successfully!', 'success') - - return redirect(url_for('admin_projects')) - -@app.route('/api/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({ - 'user': { - 'id': member.id, - 'username': member.username, - 'email': member.email - }, - 'daily_hours': daily_hours, - 'total_hours': total_hours, - 'entries': formatted_entries - }) - - # Generate a list of dates in the range for the table header - date_range = [] - current_date = start_date - while current_date <= end_date: - date_range.append(current_date.strftime('%Y-%m-%d')) - current_date += timedelta(days=1) - - return jsonify({ - 'success': True, - 'team': { - 'id': team.id, - 'name': team.name, - 'description': team.description - }, - 'team_data': team_data, - 'date_range': date_range, - 'start_date': start_date.isoformat(), - 'end_date': end_date.isoformat() - }) - -@app.route('/export') -def export(): - 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') - -@app.route('/download_export') -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')) - - # 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')) - - # 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 routes moved to routes/export.py @app.route('/analytics') @@ -3549,90 +1980,8 @@ def analytics_data(): logger.error(f"Error in analytics_data: {str(e)}") return jsonify({'error': 'Internal server error'}), 500 -def get_filtered_analytics_data(user, mode, start_date=None, end_date=None, project_filter=None): - """Get filtered time entry data for analytics""" - # Base query - query = TimeEntry.query +# get_filtered_analytics_data moved to routes/export_api.py - # 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() - - -def get_filtered_tasks_for_burndown(user, mode, start_date=None, end_date=None, project_filter=None): - """Get filtered tasks for burndown chart""" - # 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() - - -@app.route('/api/companies//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]) @app.route('/api/system-admin/stats') @system_admin_required @@ -3689,1574 +2038,10 @@ def api_system_admin_stats(): }) @app.route('/api/system-admin/companies//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] - }) - @app.route('/api/system-admin/users//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' - }) - @app.route('/api/system-admin/companies//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) +# Analytics export API moved to routes/export_api.py - # 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 - from datetime import datetime, timedelta - 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': sum(role_counts.values()), - 'by_role': role_counts - }, - 'structure': { - 'teams': team_count, - 'projects': project_count, - 'active_projects': active_projects - }, - 'activity': { - 'weekly_entries': weekly_entries, - 'monthly_entries': monthly_entries, - 'active_sessions': active_sessions - } - }) - -@app.route('/api/analytics/export') -@login_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')) - -# Task Management Routes -@app.route('/admin/projects//tasks') -@role_required(Role.TEAM_MEMBER) # All authenticated users can view tasks -@company_required -def manage_project_tasks(project_id): - project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first_or_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('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) - - - -# Unified Task Management Route -@app.route('/tasks') -@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, - db.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, - db.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) - -# Sprint Management Route -@app.route('/sprints') -@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, - db.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) - -# Task API Routes -@app.route('/api/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', 'NOT_STARTED')], - 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)}) - -@app.route('/api/tasks/', 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)}) - -@app.route('/api/tasks/', 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)}) - -@app.route('/api/tasks/', 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)}) - -# Unified Task Management APIs -@app.route('/api/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)}) - -@app.route('/api/tasks//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 COMPLETED - if task_status == TaskStatus.COMPLETED: - task.completed_date = datetime.now().date() - elif old_status == TaskStatus.COMPLETED: - # Clear completion date if moving away from completed - 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 -@app.route('/api/tasks//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 - task = Task.query.filter_by(id=task_id, 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)}) - - -@app.route('/api/tasks//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.filter_by(id=task_id, 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.filter_by( - task_number=task_number, - 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)}) - - -@app.route('/api/tasks//dependencies/', 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.filter_by(id=task_id, 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 -@app.route('/api/tasks//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)}) - - -@app.route('/api/tasks//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)}) - - -# Sprint Management APIs -@app.route('/api/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)}) - -@app.route('/api/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)}) - -@app.route('/api/sprints/', 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)}) - -@app.route('/api/sprints/', 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)}) - -# Subtask API Routes -@app.route('/api/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', 'NOT_STARTED')], - 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)}) - -@app.route('/api/subtasks/', 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)}) - -@app.route('/api/subtasks/', 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)}) - -@app.route('/api/subtasks/', 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 -@app.route('/api/tasks//comments') -@login_required -@company_required -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)}) - -@app.route('/api/tasks//comments', methods=['POST']) -@login_required -@company_required -def create_task_comment(task_id): - """Create a new comment on a task""" - 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() - content = data.get('content', '').strip() - visibility = data.get('visibility', 'COMPANY') - parent_comment_id = data.get('parent_comment_id') - - if not content: - return jsonify({'success': False, 'message': 'Comment content is required'}) - - # Check visibility settings - company_settings = CompanySettings.query.filter_by(company_id=g.user.company_id).first() - if visibility == 'TEAM' and company_settings and not company_settings.allow_team_visibility_comments: - visibility = 'COMPANY' - - # Validate parent comment if provided - if parent_comment_id: - parent_comment = Comment.query.filter_by( - id=parent_comment_id, - task_id=task_id - ).first() - - if not parent_comment or not parent_comment.can_user_view(g.user): - return jsonify({'success': False, 'message': 'Parent comment not found or access denied'}) - - # Create comment - comment = Comment( - content=content, - task_id=task_id, - parent_comment_id=parent_comment_id, - visibility=CommentVisibility[visibility], - created_by_id=g.user.id - ) - - db.session.add(comment) - db.session.commit() - - # Log system event - SystemEvent.log_event( - event_type='comment_created', - event_category='task', - description=f'Comment added to task {task.task_number}', - user_id=g.user.id, - company_id=g.user.company_id, - event_metadata={'task_id': task_id, 'comment_id': comment.id} - ) - - # Return the created comment - comment_data = { - 'id': comment.id, - 'content': comment.content, - 'visibility': comment.visibility.value, - 'is_edited': comment.is_edited, - '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': True, - 'can_delete': True - } - - return jsonify({ - 'success': True, - 'message': 'Comment posted successfully', - 'comment': comment_data - }) - - except Exception as e: - db.session.rollback() - return jsonify({'success': False, 'message': str(e)}) - -@app.route('/api/comments/', methods=['PUT']) -@login_required -@company_required -def update_comment(comment_id): - """Update an existing 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'}) - - if not comment.can_user_edit(g.user): - return jsonify({'success': False, 'message': 'You cannot edit this comment'}) - - data = request.get_json() - content = data.get('content', '').strip() - - if not content: - return jsonify({'success': False, 'message': 'Comment content is required'}) - - comment.content = content - comment.is_edited = True - comment.edited_at = datetime.now() - - db.session.commit() - - return jsonify({ - 'success': True, - 'message': 'Comment updated successfully', - 'edited_at': comment.edited_at.isoformat() - }) - - except Exception as e: - db.session.rollback() - return jsonify({'success': False, 'message': str(e)}) - -@app.route('/api/comments/', methods=['DELETE']) -@login_required -@company_required -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'}) - - if not comment.can_user_delete(g.user): - return jsonify({'success': False, 'message': 'You cannot delete this comment'}) - - # Log system event before deletion - SystemEvent.log_event( - event_type='comment_deleted', - event_category='task', - description=f'Comment deleted from task {comment.task.task_number}', - user_id=g.user.id, - company_id=g.user.company_id, - event_metadata={'task_id': comment.task_id, 'comment_id': comment.id} - ) - - db.session.delete(comment) - db.session.commit() - - return jsonify({ - 'success': True, - 'message': 'Comment deleted successfully' - }) - - except Exception as e: - db.session.rollback() - return jsonify({'success': False, 'message': str(e)}) - -# Category Management API Routes -@app.route('/api/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)}) - -@app.route('/api/admin/categories/', 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)}) - -@app.route('/api/admin/categories/', 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)}) # Dashboard API Endpoints @app.route('/api/dashboard') @@ -5279,6 +2064,7 @@ def get_dashboard(): widgets = DashboardWidget.query.filter_by(dashboard_id=dashboard.id).order_by(DashboardWidget.grid_y, DashboardWidget.grid_x).all() logger.info(f"Found {len(widgets)} widgets for dashboard {dashboard.id}") + logger.info(f"Widget details: {[(w.id, w.widget_type.value, w.grid_x, w.grid_y) for w in widgets]}") # Convert to JSON format widget_data = [] @@ -5472,13 +2258,24 @@ def delete_widget(widget_id): ).first() if not widget: + logger.error(f"Widget {widget_id} not found for dashboard {dashboard.id}") return jsonify({'success': False, 'error': 'Widget not found'}) + logger.info(f"Deleting widget {widget_id} of type {widget.widget_type.value} from dashboard {dashboard.id}") + + # Log all widgets before deletion + all_widgets = DashboardWidget.query.filter_by(dashboard_id=dashboard.id).all() + logger.info(f"Widgets before deletion: {[(w.id, w.widget_type.value) for w in all_widgets]}") + # No need to update positions for grid-based layout db.session.delete(widget) db.session.commit() + # Log all widgets after deletion + remaining_widgets = DashboardWidget.query.filter_by(dashboard_id=dashboard.id).all() + logger.info(f"Widgets after deletion: {[(w.id, w.widget_type.value) for w in remaining_widgets]}") + return jsonify({'success': True, 'message': 'Widget deleted successfully'}) except Exception as e: @@ -5626,7 +2423,8 @@ def get_widget_data(widget_id): if task_filter == 'assigned': tasks = Task.query.filter_by(assigned_to_id=g.user.id) elif task_filter == 'created': - tasks = Task.query.filter_by(created_by_id=g.user.id) + # Filter by created tasks - using assigned_to as fallback since created_by_id was removed + tasks = Task.query.filter_by(assigned_to_id=g.user.id) else: # Get tasks from user's projects if g.user.team_id: @@ -5830,79 +2628,6 @@ def get_current_timer_status(): return jsonify({'success': False, 'error': str(e)}) # Smart Search API Endpoints -@app.route('/api/search/users') -@role_required(Role.TEAM_MEMBER) -@company_required -def search_users(): - """Search for users for smart search auto-completion""" - try: - query = request.args.get('q', '').strip() - - if not query: - return jsonify({'success': True, 'users': []}) - - # Search users in the same company - users = User.query.filter( - User.company_id == g.user.company_id, - User.username.ilike(f'%{query}%') - ).limit(10).all() - - user_list = [ - { - 'id': user.id, - 'username': user.username, - 'full_name': f"{user.first_name} {user.last_name}" if user.first_name and user.last_name else user.username - } - for user in users - ] - - return jsonify({'success': True, 'users': user_list}) - - except Exception as e: - logger.error(f"Error in search_users: {str(e)}") - return jsonify({'success': False, 'message': str(e)}) - -@app.route('/api/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, - db.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)}) - @app.route('/api/search/sprints') @role_required(Role.TEAM_MEMBER) @company_required @@ -5910,22 +2635,22 @@ def search_sprints(): """Search for sprints for smart search auto-completion""" try: query = request.args.get('q', '').strip() - + if not query: return jsonify({'success': True, 'sprints': []}) - + # Search sprints in the same company sprints = Sprint.query.filter( Sprint.company_id == g.user.company_id, Sprint.name.ilike(f'%{query}%') ).limit(10).all() - + # Filter sprints user has access to accessible_sprints = [ - sprint for sprint in sprints + sprint for sprint in sprints if sprint.can_user_access(g.user) ] - + sprint_list = [ { 'id': sprint.id, @@ -5934,9 +2659,9 @@ def search_sprints(): } for sprint in accessible_sprints ] - + return jsonify({'success': True, 'sprints': sprint_list}) - + except Exception as e: logger.error(f"Error in search_sprints: {str(e)}") return jsonify({'success': False, 'message': str(e)}) diff --git a/data_formatting.py b/data_formatting.py index ab3b647..8540033 100644 --- a/data_formatting.py +++ b/data_formatting.py @@ -190,7 +190,7 @@ def format_burndown_data(tasks, start_date, end_date): # Task is remaining if: # 1. It's not completed, OR # 2. It was completed after this date - if task.status != TaskStatus.COMPLETED: + if task.status != TaskStatus.DONE: remaining_count += 1 elif task.completed_date and task.completed_date > date_obj: remaining_count += 1 diff --git a/migrations/migration_list.txt b/migrations/migration_list.txt new file mode 100644 index 0000000..00816f9 --- /dev/null +++ b/migrations/migration_list.txt @@ -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 \ No newline at end of file diff --git a/migrations/old_migrations/00_migration_summary.py b/migrations/old_migrations/00_migration_summary.py new file mode 100755 index 0000000..48c1e61 --- /dev/null +++ b/migrations/old_migrations/00_migration_summary.py @@ -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() \ No newline at end of file diff --git a/migrate_db.py b/migrations/old_migrations/01_migrate_db.py similarity index 99% rename from migrate_db.py rename to migrations/old_migrations/01_migrate_db.py index b98cd2c..5bb67aa 100644 --- a/migrate_db.py +++ b/migrations/old_migrations/01_migrate_db.py @@ -10,6 +10,9 @@ import sys import argparse from datetime import datetime +# Add parent directory to path to import app +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + # Try to import from Flask app context if available try: from app import app, db diff --git a/migrate_sqlite_to_postgres.py b/migrations/old_migrations/02_migrate_sqlite_to_postgres.py similarity index 95% rename from migrate_sqlite_to_postgres.py rename to migrations/old_migrations/02_migrate_sqlite_to_postgres.py index 38d0dac..2f2a2eb 100644 --- a/migrate_sqlite_to_postgres.py +++ b/migrations/old_migrations/02_migrate_sqlite_to_postgres.py @@ -13,6 +13,9 @@ from datetime import datetime from psycopg2.extras import RealDictCursor import json +# Add parent directory to path to import app +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + # Configure logging logging.basicConfig( level=logging.INFO, @@ -183,6 +186,15 @@ class SQLiteToPostgresMigration: data_row.append(value) data_rows.append(tuple(data_row)) + # Check if we should clear existing data first (for tables with unique constraints) + if table_name in ['company', 'team', 'user']: + postgres_cursor.execute(f'SELECT COUNT(*) FROM "{table_name}"') + existing_count = postgres_cursor.fetchone()[0] + if existing_count > 0: + logger.warning(f"Table {table_name} already has {existing_count} rows. Skipping to avoid duplicates.") + self.migration_stats[table_name] = 0 + return True + # Insert data in batches batch_size = 1000 for i in range(0, len(data_rows), batch_size): diff --git a/migrations/old_migrations/02_migrate_sqlite_to_postgres_fixed.py b/migrations/old_migrations/02_migrate_sqlite_to_postgres_fixed.py new file mode 100644 index 0000000..f6e8004 --- /dev/null +++ b/migrations/old_migrations/02_migrate_sqlite_to_postgres_fixed.py @@ -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()) \ No newline at end of file diff --git a/migrations/old_migrations/03_add_dashboard_columns.py b/migrations/old_migrations/03_add_dashboard_columns.py new file mode 100644 index 0000000..2be4d3c --- /dev/null +++ b/migrations/old_migrations/03_add_dashboard_columns.py @@ -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() \ No newline at end of file diff --git a/migrations/old_migrations/04_add_user_preferences_columns.py b/migrations/old_migrations/04_add_user_preferences_columns.py new file mode 100755 index 0000000..6ad579b --- /dev/null +++ b/migrations/old_migrations/04_add_user_preferences_columns.py @@ -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() \ No newline at end of file diff --git a/migrations/old_migrations/05_fix_task_status_enum.py b/migrations/old_migrations/05_fix_task_status_enum.py new file mode 100755 index 0000000..fcec4cc --- /dev/null +++ b/migrations/old_migrations/05_fix_task_status_enum.py @@ -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() \ No newline at end of file diff --git a/migrations/old_migrations/06_add_archived_status.py b/migrations/old_migrations/06_add_archived_status.py new file mode 100755 index 0000000..65089ad --- /dev/null +++ b/migrations/old_migrations/06_add_archived_status.py @@ -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() \ No newline at end of file diff --git a/migrations/old_migrations/07_fix_company_work_config_columns.py b/migrations/old_migrations/07_fix_company_work_config_columns.py new file mode 100755 index 0000000..f33fbe3 --- /dev/null +++ b/migrations/old_migrations/07_fix_company_work_config_columns.py @@ -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() \ No newline at end of file diff --git a/migrations/old_migrations/08_fix_work_region_enum.py b/migrations/old_migrations/08_fix_work_region_enum.py new file mode 100755 index 0000000..d06bb90 --- /dev/null +++ b/migrations/old_migrations/08_fix_work_region_enum.py @@ -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() \ No newline at end of file diff --git a/migrations/old_migrations/09_add_germany_to_workregion.py b/migrations/old_migrations/09_add_germany_to_workregion.py new file mode 100755 index 0000000..4546890 --- /dev/null +++ b/migrations/old_migrations/09_add_germany_to_workregion.py @@ -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() \ No newline at end of file diff --git a/migrations/old_migrations/10_add_company_settings_columns.py b/migrations/old_migrations/10_add_company_settings_columns.py new file mode 100755 index 0000000..fa028a3 --- /dev/null +++ b/migrations/old_migrations/10_add_company_settings_columns.py @@ -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() \ No newline at end of file diff --git a/migrations/old_migrations/11_fix_company_work_config_usage.py b/migrations/old_migrations/11_fix_company_work_config_usage.py new file mode 100755 index 0000000..bed6528 --- /dev/null +++ b/migrations/old_migrations/11_fix_company_work_config_usage.py @@ -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'
.*?additional_break.*?
\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() \ No newline at end of file diff --git a/migrations/old_migrations/12_fix_task_status_usage.py b/migrations/old_migrations/12_fix_task_status_usage.py new file mode 100755 index 0000000..3f90e76 --- /dev/null +++ b/migrations/old_migrations/12_fix_task_status_usage.py @@ -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() \ No newline at end of file diff --git a/migrations/old_migrations/13_fix_work_region_usage.py b/migrations/old_migrations/13_fix_work_region_usage.py new file mode 100755 index 0000000..bed60a1 --- /dev/null +++ b/migrations/old_migrations/13_fix_work_region_usage.py @@ -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() \ No newline at end of file diff --git a/migrations/old_migrations/14_fix_removed_fields.py b/migrations/old_migrations/14_fix_removed_fields.py new file mode 100755 index 0000000..9c25af6 --- /dev/null +++ b/migrations/old_migrations/14_fix_removed_fields.py @@ -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, + '', + 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))*' + re.escape(field) + r'.*?\s*' + content = re.sub( + pattern, + f'\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() \ No newline at end of file diff --git a/repair_roles.py b/migrations/old_migrations/15_repair_user_roles.py similarity index 70% rename from repair_roles.py rename to migrations/old_migrations/15_repair_user_roles.py index 8bec4ae..06bbed0 100644 --- a/repair_roles.py +++ b/migrations/old_migrations/15_repair_user_roles.py @@ -1,7 +1,23 @@ -from app import app, db -from models import User, Role +#!/usr/bin/env python3 +""" +Repair user roles from string to enum values +""" + +import os +import sys import logging +# Add parent directory to path to import app +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +try: + from app import app, db + from models import User, Role +except Exception as e: + print(f"Error importing modules: {e}") + print("This migration requires Flask app context. Skipping...") + sys.exit(0) + logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -44,4 +60,8 @@ def repair_user_roles(): logger.info("Role repair completed") if __name__ == "__main__": - repair_user_roles() \ No newline at end of file + try: + repair_user_roles() + except Exception as e: + logger.error(f"Migration failed: {e}") + sys.exit(1) \ No newline at end of file diff --git a/migrations/old_migrations/19_add_company_invitations.py b/migrations/old_migrations/19_add_company_invitations.py new file mode 100644 index 0000000..e4c4069 --- /dev/null +++ b/migrations/old_migrations/19_add_company_invitations.py @@ -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) \ No newline at end of file diff --git a/migrations/old_migrations/20_add_company_updated_at.py b/migrations/old_migrations/20_add_company_updated_at.py new file mode 100755 index 0000000..b9adf2d --- /dev/null +++ b/migrations/old_migrations/20_add_company_updated_at.py @@ -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) \ No newline at end of file diff --git a/migrations/old_migrations/run_all_db_migrations.py b/migrations/old_migrations/run_all_db_migrations.py new file mode 100755 index 0000000..af837f4 --- /dev/null +++ b/migrations/old_migrations/run_all_db_migrations.py @@ -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()) \ No newline at end of file diff --git a/migrations/old_migrations/run_code_migrations.py b/migrations/old_migrations/run_code_migrations.py new file mode 100755 index 0000000..54d630c --- /dev/null +++ b/migrations/old_migrations/run_code_migrations.py @@ -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()) \ No newline at end of file diff --git a/migrations/postgres_only_migration.py b/migrations/postgres_only_migration.py new file mode 100755 index 0000000..545e03c --- /dev/null +++ b/migrations/postgres_only_migration.py @@ -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()) \ No newline at end of file diff --git a/migrations/run_postgres_migrations.py b/migrations/run_postgres_migrations.py new file mode 100755 index 0000000..235ee90 --- /dev/null +++ b/migrations/run_postgres_migrations.py @@ -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()) \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..897ca0a --- /dev/null +++ b/models/__init__.py @@ -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' +] \ No newline at end of file diff --git a/models/announcement.py b/models/announcement.py new file mode 100644 index 0000000..8f707c8 --- /dev/null +++ b/models/announcement.py @@ -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'' + + 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)] \ No newline at end of file diff --git a/models/base.py b/models/base.py new file mode 100644 index 0000000..da23781 --- /dev/null +++ b/models/base.py @@ -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) \ No newline at end of file diff --git a/models/company.py b/models/company.py new file mode 100644 index 0000000..3eb9772 --- /dev/null +++ b/models/company.py @@ -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'' + + 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'' + + @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]) \ No newline at end of file diff --git a/models/dashboard.py b/models/dashboard.py new file mode 100644 index 0000000..e79ed2d --- /dev/null +++ b/models/dashboard.py @@ -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'' + + @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'' + + 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 \ No newline at end of file diff --git a/models/enums.py b/models/enums.py new file mode 100644 index 0000000..7dcae4a --- /dev/null +++ b/models/enums.py @@ -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 \ No newline at end of file diff --git a/models/invitation.py b/models/invitation.py new file mode 100644 index 0000000..0c6e6b3 --- /dev/null +++ b/models/invitation.py @@ -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'' \ No newline at end of file diff --git a/models/project.py b/models/project.py new file mode 100644 index 0000000..ecfcf55 --- /dev/null +++ b/models/project.py @@ -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'' + + 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'' \ No newline at end of file diff --git a/models/sprint.py b/models/sprint.py new file mode 100644 index 0000000..9823c5f --- /dev/null +++ b/models/sprint.py @@ -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'' + + @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] \ No newline at end of file diff --git a/models/system.py b/models/system.py new file mode 100644 index 0000000..7ff47a3 --- /dev/null +++ b/models/system.py @@ -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'' + + +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'' + + @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'' + + @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 \ No newline at end of file diff --git a/models/task.py b/models/task.py new file mode 100644 index 0000000..7810319 --- /dev/null +++ b/models/task.py @@ -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'' + + @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'' + + +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'' + + @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'' + + 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 \ No newline at end of file diff --git a/models/team.py b/models/team.py new file mode 100644 index 0000000..417ec60 --- /dev/null +++ b/models/team.py @@ -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'' \ No newline at end of file diff --git a/models/time_entry.py b/models/time_entry.py new file mode 100644 index 0000000..ca9d8d1 --- /dev/null +++ b/models/time_entry.py @@ -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'' \ No newline at end of file diff --git a/models/user.py b/models/user.py new file mode 100644 index 0000000..14c702e --- /dev/null +++ b/models/user.py @@ -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'' + + +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') \ No newline at end of file diff --git a/models/work_config.py b/models/work_config.py new file mode 100644 index 0000000..0e85b5c --- /dev/null +++ b/models/work_config.py @@ -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'' \ No newline at end of file diff --git a/models.py b/models_old.py similarity index 100% rename from models.py rename to models_old.py diff --git a/routes/announcements.py b/routes/announcements.py new file mode 100644 index 0000000..957cdda --- /dev/null +++ b/routes/announcements.py @@ -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('//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('//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')) \ No newline at end of file diff --git a/routes/auth.py b/routes/auth.py new file mode 100644 index 0000000..df0e6f1 --- /dev/null +++ b/routes/auth.py @@ -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 \ No newline at end of file diff --git a/routes/company.py b/routes/company.py new file mode 100644 index 0000000..ab49cc2 --- /dev/null +++ b/routes/company.py @@ -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) \ No newline at end of file diff --git a/routes/company_api.py b/routes/company_api.py new file mode 100644 index 0000000..4563c2d --- /dev/null +++ b/routes/company_api.py @@ -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//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//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 + } + }) \ No newline at end of file diff --git a/routes/export.py b/routes/export.py new file mode 100644 index 0000000..1a4ac7c --- /dev/null +++ b/routes/export.py @@ -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')) \ No newline at end of file diff --git a/routes/export_api.py b/routes/export_api.py new file mode 100644 index 0000000..427efb5 --- /dev/null +++ b/routes/export_api.py @@ -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')) \ No newline at end of file diff --git a/routes/invitations.py b/routes/invitations.py new file mode 100644 index 0000000..341a71b --- /dev/null +++ b/routes/invitations.py @@ -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/', 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/', 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')) \ No newline at end of file diff --git a/routes/projects.py b/routes/projects.py new file mode 100644 index 0000000..a5221c0 --- /dev/null +++ b/routes/projects.py @@ -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/', 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/', 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('//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) \ No newline at end of file diff --git a/routes/projects_api.py b/routes/projects_api.py new file mode 100644 index 0000000..aa40057 --- /dev/null +++ b/routes/projects_api.py @@ -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/', 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/', 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)}) \ No newline at end of file diff --git a/routes/sprints.py b/routes/sprints.py new file mode 100644 index 0000000..1621882 --- /dev/null +++ b/routes/sprints.py @@ -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) \ No newline at end of file diff --git a/routes/sprints_api.py b/routes/sprints_api.py new file mode 100644 index 0000000..865751a --- /dev/null +++ b/routes/sprints_api.py @@ -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/', 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/', 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)}) \ No newline at end of file diff --git a/routes/system_admin.py b/routes/system_admin.py new file mode 100644 index 0000000..404ba24 --- /dev/null +++ b/routes/system_admin.py @@ -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/') +@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//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) \ No newline at end of file diff --git a/routes/tasks.py b/routes/tasks.py new file mode 100644 index 0000000..bc3e29e --- /dev/null +++ b/routes/tasks.py @@ -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) + + diff --git a/routes/tasks_api.py b/routes/tasks_api.py new file mode 100644 index 0000000..cf22656 --- /dev/null +++ b/routes/tasks_api.py @@ -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/', 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/', 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/', 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//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//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//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//dependencies/', 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//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//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/', 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/', 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/', 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//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/', 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)}) \ No newline at end of file diff --git a/routes/teams.py b/routes/teams.py new file mode 100644 index 0000000..48c69de --- /dev/null +++ b/routes/teams.py @@ -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/', 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/', 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('/', 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 + ) \ No newline at end of file diff --git a/routes/teams_api.py b/routes/teams_api.py new file mode 100644 index 0000000..0d7878e --- /dev/null +++ b/routes/teams_api.py @@ -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//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]) \ No newline at end of file diff --git a/routes/users.py b/routes/users.py new file mode 100644 index 0000000..c7c928d --- /dev/null +++ b/routes/users.py @@ -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/', 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/', 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/', 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//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//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')) \ No newline at end of file diff --git a/routes/users_api.py b/routes/users_api.py new file mode 100644 index 0000000..b851119 --- /dev/null +++ b/routes/users_api.py @@ -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//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] + }) \ No newline at end of file diff --git a/startup.sh b/startup.sh index f20ada2..09371c1 100755 --- a/startup.sh +++ b/startup.sh @@ -11,40 +11,7 @@ while ! pg_isready -h db -p 5432 -U "$POSTGRES_USER" > /dev/null 2>&1; do done echo "PostgreSQL is ready!" -# Check if SQLite database exists and has data -SQLITE_PATH="/data/timetrack.db" -if [ -f "$SQLITE_PATH" ]; then - echo "SQLite database found at $SQLITE_PATH" - - # Check if PostgreSQL database is empty - POSTGRES_TABLE_COUNT=$(psql "$DATABASE_URL" -t -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public';" 2>/dev/null || echo "0") - - if [ "$POSTGRES_TABLE_COUNT" -eq 0 ]; then - echo "PostgreSQL database is empty, running migration..." - - # Create a backup of SQLite database - cp "$SQLITE_PATH" "${SQLITE_PATH}.backup.$(date +%Y%m%d_%H%M%S)" - echo "Created SQLite backup" - - # Run migration - python migrate_sqlite_to_postgres.py - - if [ $? -eq 0 ]; then - echo "Migration completed successfully!" - - # Rename SQLite database to indicate it's been migrated - mv "$SQLITE_PATH" "${SQLITE_PATH}.migrated" - echo "SQLite database renamed to indicate migration completion" - else - echo "Migration failed! Check migration.log for details" - exit 1 - fi - else - echo "PostgreSQL database already contains tables, skipping migration" - fi -else - echo "No SQLite database found, starting with fresh PostgreSQL database" -fi +# SQLite to PostgreSQL migration is now handled by the migration system below # Initialize database tables if they don't exist echo "Ensuring database tables exist..." @@ -55,6 +22,35 @@ with app.app_context(): print('Database tables created/verified') " +# Run all database schema migrations +echo "" +echo "=== Running Database Schema Migrations ===" +if [ -d "migrations" ] && [ -f "migrations/run_all_db_migrations.py" ]; then + echo "Checking and applying database schema updates..." + python migrations/run_all_db_migrations.py + if [ $? -ne 0 ]; then + echo "⚠️ Some database migrations had issues, but continuing..." + fi +else + echo "No migrations directory found, skipping database migrations..." +fi + +# Run code migrations to update code for model changes +echo "" +echo "=== Running Code Migrations ===" +echo "Code migrations temporarily disabled for debugging" +# if [ -d "migrations" ] && [ -f "migrations/run_code_migrations.py" ]; then +# echo "Checking and applying code updates for model changes..." +# python migrations/run_code_migrations.py +# if [ $? -ne 0 ]; then +# echo "⚠️ Code migrations had issues, but continuing..." +# fi +# else +# echo "No migrations directory found, skipping code migrations..." +# fi + # Start the Flask application with gunicorn +echo "" +echo "=== Starting Application ===" echo "Starting Flask application with gunicorn..." exec gunicorn --bind 0.0.0.0:5000 --workers 4 --threads 2 --timeout 30 app:app \ No newline at end of file diff --git a/startup_postgres.sh b/startup_postgres.sh new file mode 100755 index 0000000..4107b2f --- /dev/null +++ b/startup_postgres.sh @@ -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 \ No newline at end of file diff --git a/static/js/subtasks.js b/static/js/subtasks.js index b41ff69..2c764c1 100644 --- a/static/js/subtasks.js +++ b/static/js/subtasks.js @@ -73,7 +73,7 @@ function renderSubtasks() { function addSubtask() { const newSubtask = { name: '', - status: 'NOT_STARTED', + status: 'TODO', priority: 'MEDIUM', assigned_to_id: null, isNew: true @@ -143,7 +143,7 @@ function updateSubtaskAssignee(index, assigneeId) { // Toggle subtask status function toggleSubtaskStatus(index) { const subtask = currentSubtasks[index]; - const newStatus = subtask.status === 'COMPLETED' ? 'NOT_STARTED' : 'COMPLETED'; + const newStatus = subtask.status === 'DONE' ? 'TODO' : 'DONE'; if (subtask.id) { // Update in database diff --git a/static/js/time-tracking.js b/static/js/time-tracking.js new file mode 100644 index 0000000..c646d6c --- /dev/null +++ b/static/js/time-tracking.js @@ -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 = ''; + 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 = ''; + + 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 = ''; + } + }) + .catch(error => { + console.error('Error fetching tasks:', error); + taskSelectElement.disabled = true; + taskSelectElement.innerHTML = ``; + }); + } + + 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); \ No newline at end of file diff --git a/templates/admin_company.html b/templates/admin_company.html index ef740e5..b3e8ca6 100644 --- a/templates/admin_company.html +++ b/templates/admin_company.html @@ -1,213 +1,897 @@ {% extends "layout.html" %} {% block content %} -
-
-

Company Management

- -
- - -
-

Company Information

-
-
-
-

{{ company.name }}

- - {{ 'Active' if company.is_active else 'Inactive' }} - -
-
-
- Company Code: - {{ company.slug }} -
-
- Created: - {{ company.created_at.strftime('%Y-%m-%d %H:%M') }} -
-
- Max Users: - {{ company.max_users or 'Unlimited' }} -
- {% if company.description %} -
- Description: - {{ company.description }} -
- {% endif %} -
+
+ + - +
-

Company Statistics

-
-
-

{{ stats.total_users }}

-

Total Users

-
-
-

{{ stats.total_teams }}

-

Teams

-
-
-

{{ stats.total_projects }}

-

Total Projects

-
-
-

{{ stats.active_projects }}

-

Active Projects

-
+
+
{{ stats.total_users }}
+
Total Users
+ View all → +
+
+
{{ stats.total_teams }}
+
Teams
+ Manage → +
+
+
{{ stats.total_projects }}
+
Total Projects
+ View all → +
+
+
{{ stats.active_projects }}
+
Active Projects
+ Manage →
- -
-

Management

-
-
-

Users

-

Manage user accounts, roles, and permissions within your company.

- Manage Users + +
+ +
+ +
+
+

+ ℹ️ + Company Information +

+
+
+
+ + +
+ + + The official name of your company +
+ +
+
+ + + Leave empty for unlimited +
+
+ +
+ + Company is {{ 'active' if company.is_active else 'inactive' }} +
+
+
+ +
+ + + Optional: Describe your company's mission or purpose +
+ +
+
+ 🔑 +
+ +
+ + +
+ Legacy: Use email invitations instead +
+
+
+ 📅 +
+ + {{ company.created_at.strftime('%B %d, %Y at %I:%M %p') }} +
+
+
+ +
+ +
+
+
- -
-

Teams

-

Create and manage teams to organize your company structure.

- Manage Teams -
- -
-

Projects

-

Set up and manage projects for time tracking and organization.

- Manage Projects -
- - -
- -
-

User Registration

-
-

Share this company code with new users for registration:

-
- - + +
+ +
+
+

+ 📋 + Work Policies +

+
+
+ +
+

Regional Preset

+
+ + + +
+
+ + +
+

+ Current Configuration + {{ work_config.work_region.value if work_config.work_region else 'Custom' }} +

+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+
+ + +
+
+

+ 👤 + User Registration +

+
+
+
+ + +
+
+
+ +
+
+

Enable User Registration

+

Allow new users to register accounts using the company code

+
+
+ +
+
+ +
+
+

Require Email Verification

+

New users must verify their email address before accessing the system

+
+
+
+ +
+ +
+
+
-

New users will need this code when registering for your company.

- - + + + {% endblock %} \ No newline at end of file diff --git a/templates/admin_projects.html b/templates/admin_projects.html index 25174b3..2c400ba 100644 --- a/templates/admin_projects.html +++ b/templates/admin_projects.html @@ -1,285 +1,491 @@ {% extends "layout.html" %} {% block content %} -
-
-

Project Management

-
- Create New Project - +
+ + + + {% if projects %} +
+
+
{{ projects|length }}
+
Total Projects
+
+
+
{{ projects|selectattr('is_active')|list|length }}
+
Active Projects
+
+
+
{{ categories|length }}
+
Categories
+
+
+
{{ projects|map(attribute='time_entries')|map('length')|sum }}
+
Time Entries
+
+
+ {% endif %} +