6846 lines
259 KiB
Python
6846 lines
259 KiB
Python
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, Note, NoteLink, NoteVisibility, NoteFolder
|
|
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
|
|
)
|
|
from time_utils import apply_time_rounding, round_duration_to_interval, get_user_rounding_settings
|
|
import logging
|
|
from datetime import datetime, time, timedelta
|
|
import os
|
|
import csv
|
|
import io
|
|
import pandas as pd
|
|
from sqlalchemy import func
|
|
from functools import wraps
|
|
from flask_mail import Mail, Message
|
|
from dotenv import load_dotenv
|
|
from password_utils import PasswordValidator
|
|
from werkzeug.security import check_password_hash
|
|
|
|
# Load environment variables from .env file
|
|
load_dotenv()
|
|
|
|
# Configure logging
|
|
logging.basicConfig(level=logging.DEBUG)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
app = Flask(__name__)
|
|
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:////data/timetrack.db')
|
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
|
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev_key_for_timetrack')
|
|
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # Session lasts for 7 days
|
|
|
|
# Configure Flask-Mail
|
|
app.config['MAIL_SERVER'] = os.environ.get('MAIL_SERVER', 'smtp.example.com')
|
|
app.config['MAIL_PORT'] = int(os.environ.get('MAIL_PORT') or 587)
|
|
app.config['MAIL_USE_TLS'] = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1']
|
|
app.config['MAIL_USERNAME'] = os.environ.get('MAIL_USERNAME', 'your-email@example.com')
|
|
app.config['MAIL_PASSWORD'] = os.environ.get('MAIL_PASSWORD', 'your-password')
|
|
app.config['MAIL_DEFAULT_SENDER'] = os.environ.get('MAIL_DEFAULT_SENDER', 'TimeTrack <noreply@timetrack.com>') # Will be overridden by branding in mail sending functions
|
|
|
|
# Log mail configuration (without password)
|
|
logger.info(f"Mail server: {app.config['MAIL_SERVER']}")
|
|
logger.info(f"Mail port: {app.config['MAIL_PORT']}")
|
|
logger.info(f"Mail use TLS: {app.config['MAIL_USE_TLS']}")
|
|
logger.info(f"Mail username: {app.config['MAIL_USERNAME']}")
|
|
logger.info(f"Mail default sender: {app.config['MAIL_DEFAULT_SENDER']}")
|
|
|
|
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}")
|
|
|
|
is_postgresql = 'postgresql://' in database_url or 'postgres://' in database_url
|
|
print(f"DEBUG: Is PostgreSQL: {is_postgresql}")
|
|
|
|
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
|
|
|
|
def init_system_settings():
|
|
"""Initialize system settings with default values if they don't exist"""
|
|
if not SystemSettings.query.filter_by(key='registration_enabled').first():
|
|
print("Adding registration_enabled system setting...")
|
|
reg_setting = SystemSettings(
|
|
key='registration_enabled',
|
|
value='true',
|
|
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...")
|
|
email_setting = SystemSettings(
|
|
key='email_verification_required',
|
|
value='true',
|
|
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
|
|
|
|
# Call this function during app initialization
|
|
@app.before_first_request
|
|
def initialize_app():
|
|
run_migrations() # This handles all migrations including work config data
|
|
|
|
# Add this after initializing the app but before defining routes
|
|
@app.context_processor
|
|
def inject_globals():
|
|
"""Make certain variables available to all templates."""
|
|
# Get active announcements for current user
|
|
active_announcements = []
|
|
if g.user:
|
|
active_announcements = Announcement.get_active_announcements_for_user(g.user)
|
|
|
|
# Get tracking script settings
|
|
tracking_script_enabled = False
|
|
tracking_script_code = ''
|
|
|
|
try:
|
|
tracking_enabled_setting = SystemSettings.query.filter_by(key='tracking_script_enabled').first()
|
|
if tracking_enabled_setting:
|
|
tracking_script_enabled = tracking_enabled_setting.value == 'true'
|
|
|
|
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:
|
|
pass # In case database isn't available yet
|
|
return {
|
|
'Role': Role,
|
|
'AccountType': AccountType,
|
|
'current_year': datetime.now().year,
|
|
'active_announcements': active_announcements,
|
|
'tracking_script_enabled': tracking_script_enabled,
|
|
'tracking_script_code': tracking_script_code
|
|
}
|
|
|
|
# Template filters for date/time formatting
|
|
@app.template_filter('from_json')
|
|
def from_json_filter(json_str):
|
|
"""Parse JSON string to Python object."""
|
|
if not json_str:
|
|
return []
|
|
try:
|
|
import json
|
|
return json.loads(json_str)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return []
|
|
|
|
@app.template_filter('format_date')
|
|
def format_date_filter(dt):
|
|
"""Format date according to user preferences."""
|
|
if not dt or not g.user:
|
|
return dt.strftime('%Y-%m-%d') if dt else ''
|
|
|
|
from time_utils import format_date_by_preference, get_user_format_settings
|
|
date_format, _ = get_user_format_settings(g.user)
|
|
return format_date_by_preference(dt, date_format)
|
|
|
|
@app.template_filter('format_time')
|
|
def format_time_filter(dt):
|
|
"""Format time according to user preferences."""
|
|
if not dt or not g.user:
|
|
return dt.strftime('%H:%M:%S') if dt else ''
|
|
|
|
from time_utils import format_time_by_preference, get_user_format_settings
|
|
_, time_format_24h = get_user_format_settings(g.user)
|
|
return format_time_by_preference(dt, time_format_24h)
|
|
|
|
@app.template_filter('format_time_short')
|
|
def format_time_short_filter(dt):
|
|
"""Format time without seconds according to user preferences."""
|
|
if not dt or not g.user:
|
|
return dt.strftime('%H:%M') if dt else ''
|
|
|
|
from time_utils import format_time_short_by_preference, get_user_format_settings
|
|
_, time_format_24h = get_user_format_settings(g.user)
|
|
return format_time_short_by_preference(dt, time_format_24h)
|
|
|
|
@app.template_filter('format_datetime')
|
|
def format_datetime_filter(dt):
|
|
"""Format datetime according to user preferences."""
|
|
if not dt or not g.user:
|
|
return dt.strftime('%Y-%m-%d %H:%M:%S') if dt else ''
|
|
|
|
from time_utils import format_datetime_by_preference, get_user_format_settings
|
|
date_format, time_format_24h = get_user_format_settings(g.user)
|
|
return format_datetime_by_preference(dt, date_format, time_format_24h)
|
|
|
|
@app.template_filter('format_duration')
|
|
def format_duration_filter(duration_seconds):
|
|
"""Format duration in readable format."""
|
|
if duration_seconds is None:
|
|
return '00:00:00'
|
|
|
|
from time_utils import format_duration_readable
|
|
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
|
|
|
|
@app.before_request
|
|
def load_logged_in_user():
|
|
user_id = session.get('user_id')
|
|
if user_id is None:
|
|
g.user = None
|
|
g.company = None
|
|
else:
|
|
g.user = User.query.get(user_id)
|
|
if g.user:
|
|
# Set company context
|
|
if g.user.company_id:
|
|
g.company = Company.query.get(g.user.company_id)
|
|
else:
|
|
g.company = None
|
|
|
|
# Check if user has email but not verified
|
|
if g.user and not g.user.is_verified and g.user.email:
|
|
# Add a flag for templates to show email verification nag
|
|
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
|
|
else:
|
|
g.show_email_nag = False
|
|
else:
|
|
g.company = None
|
|
|
|
# Load branding settings
|
|
g.branding = BrandingSettings.get_current()
|
|
|
|
@app.route('/')
|
|
def home():
|
|
if g.user:
|
|
# Get active entry (no departure time)
|
|
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 available projects for this user (company-scoped)
|
|
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)
|
|
|
|
return render_template('index.html', title='Home',
|
|
active_entry=active_entry,
|
|
history=history,
|
|
available_projects=available_projects)
|
|
else:
|
|
return render_template('index.html', title='Home')
|
|
|
|
@app.route('/login', methods=['GET', 'POST'])
|
|
def login():
|
|
if request.method == 'POST':
|
|
username = request.form.get('username')
|
|
password = request.form.get('password')
|
|
|
|
user = User.query.filter_by(username=username).first()
|
|
|
|
if user:
|
|
# Fix role if it's a string or None
|
|
if isinstance(user.role, str) or user.role is None:
|
|
# Map string role values to enum values
|
|
role_mapping = {
|
|
'Team Member': Role.TEAM_MEMBER,
|
|
'TEAM_MEMBER': Role.TEAM_MEMBER,
|
|
'Team Leader': Role.TEAM_LEADER,
|
|
'TEAM_LEADER': Role.TEAM_LEADER,
|
|
'Supervisor': Role.SUPERVISOR,
|
|
'SUPERVISOR': Role.SUPERVISOR,
|
|
'Administrator': Role.ADMIN,
|
|
'ADMIN': Role.ADMIN
|
|
}
|
|
|
|
if isinstance(user.role, str):
|
|
user.role = role_mapping.get(user.role, Role.TEAM_MEMBER)
|
|
else:
|
|
user.role = Role.ADMIN if user.role == Role.ADMIN else Role.TEAM_MEMBER
|
|
|
|
db.session.commit()
|
|
|
|
# Now proceed with password check
|
|
if user.check_password(password):
|
|
# Check if user is blocked
|
|
if user.is_blocked:
|
|
flash('Your account has been disabled. Please contact an administrator.', 'error')
|
|
return render_template('login.html')
|
|
|
|
# Check if 2FA is enabled
|
|
if user.two_factor_enabled:
|
|
# Store user ID for 2FA verification
|
|
session['2fa_user_id'] = user.id
|
|
return redirect(url_for('verify_2fa'))
|
|
else:
|
|
# Continue with normal login process
|
|
session['user_id'] = user.id
|
|
session['username'] = user.username
|
|
session['role'] = user.role.value
|
|
|
|
# Log successful login
|
|
SystemEvent.log_event(
|
|
'user_login',
|
|
f'User {user.username} logged in successfully',
|
|
'auth',
|
|
'info',
|
|
user_id=user.id,
|
|
company_id=user.company_id,
|
|
ip_address=request.remote_addr,
|
|
user_agent=request.headers.get('User-Agent')
|
|
)
|
|
|
|
flash('Login successful!', 'success')
|
|
return redirect(url_for('home'))
|
|
|
|
# Log failed login attempt
|
|
SystemEvent.log_event(
|
|
'login_failed',
|
|
f'Failed login attempt for username: {username}',
|
|
'auth',
|
|
'warning',
|
|
ip_address=request.remote_addr,
|
|
user_agent=request.headers.get('User-Agent')
|
|
)
|
|
|
|
flash('Invalid username or password', 'error')
|
|
|
|
return render_template('login.html', title='Login')
|
|
|
|
@app.route('/logout')
|
|
def logout():
|
|
# Log logout event before clearing session
|
|
if 'user_id' in session:
|
|
user = User.query.get(session['user_id'])
|
|
if user:
|
|
SystemEvent.log_event(
|
|
'user_logout',
|
|
f'User {user.username} logged out',
|
|
'auth',
|
|
'info',
|
|
user_id=user.id,
|
|
company_id=user.company_id,
|
|
ip_address=request.remote_addr,
|
|
user_agent=request.headers.get('User-Agent')
|
|
)
|
|
|
|
session.clear()
|
|
flash('You have been logged out.', 'info')
|
|
return redirect(url_for('login'))
|
|
|
|
@app.route('/register', methods=['GET', 'POST'])
|
|
def register():
|
|
# Check if registration is enabled
|
|
registration_enabled = get_system_setting('registration_enabled', 'true') == 'true'
|
|
|
|
if not registration_enabled:
|
|
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'))
|
|
|
|
if request.method == 'POST':
|
|
username = request.form.get('username')
|
|
email = request.form.get('email')
|
|
password = request.form.get('password')
|
|
confirm_password = request.form.get('confirm_password')
|
|
company_code = request.form.get('company_code', '').strip()
|
|
|
|
# Validate input
|
|
error = None
|
|
if not username:
|
|
error = 'Username is required'
|
|
elif not password:
|
|
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()
|
|
is_valid, password_errors = validator.validate(password)
|
|
if not is_valid:
|
|
error = password_errors[0] # Show first error
|
|
|
|
# Find company by code
|
|
company = None
|
|
if company_code:
|
|
company = Company.query.filter_by(slug=company_code.lower()).first()
|
|
if not company:
|
|
error = 'Invalid company code'
|
|
|
|
# Check for existing users within the company
|
|
if company and not error:
|
|
if User.query.filter_by(username=username, company_id=company.id).first():
|
|
error = 'Username already exists in this company'
|
|
elif email and User.query.filter_by(email=email, company_id=company.id).first():
|
|
error = 'Email already registered in this company'
|
|
|
|
if error is None and company:
|
|
try:
|
|
# Check if this is the first user account in this company
|
|
is_first_user_in_company = User.query.filter_by(company_id=company.id).count() == 0
|
|
|
|
# Check if email verification is required
|
|
email_verification_required = get_system_setting('email_verification_required', 'true') == 'true'
|
|
|
|
new_user = User(
|
|
username=username,
|
|
email=email,
|
|
company_id=company.id,
|
|
is_verified=False
|
|
)
|
|
new_user.set_password(password)
|
|
|
|
# Make first user in company an admin with full privileges
|
|
if is_first_user_in_company:
|
|
new_user.role = Role.ADMIN
|
|
new_user.is_verified = True # Auto-verify first user in company
|
|
elif not email_verification_required:
|
|
# If email verification is disabled, auto-verify new users
|
|
new_user.is_verified = True
|
|
|
|
# Generate verification token (even if not needed, for consistency)
|
|
|
|
token = new_user.generate_verification_token()
|
|
|
|
db.session.add(new_user)
|
|
db.session.commit()
|
|
|
|
if is_first_user_in_company:
|
|
# First user in company gets admin privileges and is auto-verified
|
|
logger.info(f"First user account created in company {company.name}: {username} with admin privileges")
|
|
flash(f'Welcome! You are the first user in {company.name} and have been granted administrator privileges. You can now log in.', 'success')
|
|
elif not email_verification_required:
|
|
# Email verification is disabled, user can log in immediately
|
|
logger.info(f"User account created with auto-verification in company {company.name}: {username}")
|
|
flash('Registration successful! You can now log in.', 'success')
|
|
else:
|
|
# Send verification email for regular users when verification is required
|
|
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},
|
|
|
|
Thank you for registering with {g.branding.app_name}. To complete your registration, please click on the link below:
|
|
|
|
{verification_url}
|
|
|
|
This link will expire in 24 hours.
|
|
|
|
If you did not register for {g.branding.app_name}, please ignore this email.
|
|
|
|
Best regards,
|
|
The {g.branding.app_name} Team
|
|
'''
|
|
mail.send(msg)
|
|
logger.info(f"Verification email sent to {email}")
|
|
flash('Registration initiated! Please check your email to verify your account.', 'success')
|
|
|
|
return redirect(url_for('login'))
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error during registration: {str(e)}")
|
|
error = f"An error occurred during registration: {str(e)}"
|
|
|
|
flash(error, 'error')
|
|
|
|
return render_template('register.html', title='Register')
|
|
|
|
@app.route('/register/freelancer', methods=['GET', 'POST'])
|
|
def register_freelancer():
|
|
"""Freelancer registration route - creates user without company token"""
|
|
# Check if registration is enabled
|
|
registration_enabled = get_system_setting('registration_enabled', 'true') == 'true'
|
|
|
|
if not registration_enabled:
|
|
flash('Registration is currently disabled by the administrator.', 'error')
|
|
return redirect(url_for('login'))
|
|
|
|
if request.method == 'POST':
|
|
username = request.form.get('username')
|
|
email = request.form.get('email')
|
|
password = request.form.get('password')
|
|
confirm_password = request.form.get('confirm_password')
|
|
business_name = request.form.get('business_name', '').strip()
|
|
|
|
# Validate input
|
|
error = None
|
|
if not username:
|
|
error = 'Username is required'
|
|
elif not password:
|
|
error = 'Password is required'
|
|
elif password != confirm_password:
|
|
error = 'Passwords do not match'
|
|
|
|
# Validate password strength
|
|
if not error:
|
|
validator = PasswordValidator()
|
|
is_valid, password_errors = validator.validate(password)
|
|
if not is_valid:
|
|
error = password_errors[0] # Show first error
|
|
|
|
# Check for existing users globally (freelancers get unique usernames/emails)
|
|
if not error:
|
|
if User.query.filter_by(username=username).first():
|
|
error = 'Username already exists'
|
|
elif email and User.query.filter_by(email=email).first():
|
|
error = 'Email already registered'
|
|
|
|
if error is None:
|
|
try:
|
|
# Create personal company for freelancer
|
|
company_name = business_name if business_name else f"{username}'s Workspace"
|
|
|
|
# Generate unique 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 personal company
|
|
personal_company = Company(
|
|
name=company_name,
|
|
slug=slug,
|
|
description=f"Personal workspace for {username}",
|
|
is_personal=True,
|
|
max_users=1 # Limit to single user
|
|
)
|
|
|
|
db.session.add(personal_company)
|
|
db.session.flush() # Get company ID
|
|
|
|
# Create freelancer user
|
|
new_user = User(
|
|
username=username,
|
|
email=email,
|
|
company_id=personal_company.id,
|
|
account_type=AccountType.FREELANCER,
|
|
business_name=business_name if business_name else None,
|
|
role=Role.ADMIN, # Freelancers are admins of their personal company
|
|
is_verified=True # Auto-verify freelancers
|
|
)
|
|
new_user.set_password(password)
|
|
|
|
db.session.add(new_user)
|
|
db.session.commit()
|
|
|
|
logger.info(f"Freelancer account created: {username} with personal company: {company_name}")
|
|
flash(f'Welcome {username}! Your freelancer account has been created successfully. You can now log in.', 'success')
|
|
|
|
return redirect(url_for('login'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error during freelancer registration: {str(e)}")
|
|
error = f"An error occurred during registration: {str(e)}"
|
|
|
|
if error:
|
|
flash(error, 'error')
|
|
|
|
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()
|
|
|
|
# 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
|
|
|
|
# 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 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
|
|
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
|
|
)
|
|
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('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_super_admin)
|
|
|
|
@app.route('/verify_email/<token>')
|
|
def verify_email(token):
|
|
user = User.query.filter_by(verification_token=token).first()
|
|
|
|
if not user:
|
|
flash('Invalid or expired verification link.', 'error')
|
|
return redirect(url_for('login'))
|
|
|
|
if user.verify_token(token):
|
|
db.session.commit()
|
|
flash('Email verified successfully! You can now log in.', 'success')
|
|
else:
|
|
flash('Invalid or expired verification link.', 'error')
|
|
|
|
return redirect(url_for('login'))
|
|
|
|
@app.route('/dashboard')
|
|
@role_required(Role.TEAM_MEMBER)
|
|
@company_required
|
|
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/<int:user_id>', 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/<int:user_id>', methods=['POST'])
|
|
@admin_required
|
|
@company_required
|
|
def delete_user(user_id):
|
|
user = User.query.filter_by(id=user_id, company_id=g.user.company_id).first_or_404()
|
|
|
|
# Prevent deleting yourself
|
|
if user.id == session.get('user_id'):
|
|
flash('You cannot delete your own account', 'error')
|
|
return redirect(url_for('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/<int:user_id>', 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
|
|
def profile():
|
|
user = User.query.get(session['user_id'])
|
|
|
|
if request.method == 'POST':
|
|
email = request.form.get('email')
|
|
current_password = request.form.get('current_password')
|
|
new_password = request.form.get('new_password')
|
|
confirm_password = request.form.get('confirm_password')
|
|
|
|
# Validate input
|
|
error = None
|
|
if not email:
|
|
error = 'Email is required'
|
|
elif email != user.email and User.query.filter_by(email=email).first():
|
|
error = 'Email already registered'
|
|
|
|
# Password change validation
|
|
if new_password:
|
|
if not current_password:
|
|
error = 'Current password is required to set a new password'
|
|
elif not user.check_password(current_password):
|
|
error = 'Current password is incorrect'
|
|
elif new_password != confirm_password:
|
|
error = 'New passwords do not match'
|
|
else:
|
|
# Validate password strength
|
|
validator = PasswordValidator()
|
|
is_valid, password_errors = validator.validate(new_password)
|
|
if not is_valid:
|
|
error = password_errors[0] # Show first error
|
|
|
|
if error is None:
|
|
user.email = email
|
|
|
|
if new_password:
|
|
user.set_password(new_password)
|
|
|
|
db.session.commit()
|
|
flash('Profile updated successfully!', 'success')
|
|
return redirect(url_for('profile'))
|
|
|
|
flash(error, 'error')
|
|
|
|
return render_template('profile.html', title='My Profile', user=user)
|
|
|
|
@app.route('/update-avatar', methods=['POST'])
|
|
@login_required
|
|
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
|
|
import re
|
|
url_pattern = re.compile(
|
|
r'^https?://' # http:// or https://
|
|
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' # domain...
|
|
r'localhost|' # localhost...
|
|
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):
|
|
# Check if it's a service that doesn't use extensions (like gravatar)
|
|
allowed_services = ['gravatar.com', 'dicebear.com', 'ui-avatars.com', 'avatars.githubusercontent.com']
|
|
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',
|
|
event_category='user',
|
|
description=f'User {user.username} updated their avatar',
|
|
user_id=user.id,
|
|
company_id=user.company_id
|
|
)
|
|
|
|
return redirect(url_for('profile'))
|
|
|
|
@app.route('/upload-avatar', methods=['POST'])
|
|
@login_required
|
|
def upload_avatar():
|
|
"""Handle avatar file upload"""
|
|
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('/'))
|
|
if os.path.exists(old_file_path):
|
|
try:
|
|
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',
|
|
event_category='user',
|
|
description=f'User {user.username} uploaded a new avatar',
|
|
user_id=user.id,
|
|
company_id=user.company_id
|
|
)
|
|
|
|
return redirect(url_for('profile'))
|
|
|
|
@app.route('/2fa/setup', methods=['GET', 'POST'])
|
|
@login_required
|
|
def setup_2fa():
|
|
if request.method == 'POST':
|
|
# Verify the TOTP code before enabling 2FA
|
|
totp_code = request.form.get('totp_code')
|
|
|
|
if not totp_code:
|
|
flash('Please enter the verification code from your authenticator app.', 'error')
|
|
return redirect(url_for('setup_2fa'))
|
|
|
|
try:
|
|
if g.user.verify_2fa_token(totp_code, allow_setup=True):
|
|
g.user.two_factor_enabled = True
|
|
db.session.commit()
|
|
flash('Two-factor authentication has been successfully enabled!', 'success')
|
|
return redirect(url_for('profile'))
|
|
else:
|
|
flash('Invalid verification code. Please make sure your device time is synchronized and try again.', 'error')
|
|
return redirect(url_for('setup_2fa'))
|
|
except Exception as e:
|
|
logger.error(f"2FA setup error: {str(e)}")
|
|
flash('An error occurred during 2FA setup. Please try again.', 'error')
|
|
return redirect(url_for('setup_2fa'))
|
|
|
|
# GET request - show setup page
|
|
if g.user.two_factor_enabled:
|
|
flash('Two-factor authentication is already enabled.', 'info')
|
|
return redirect(url_for('profile'))
|
|
|
|
# Generate secret if not exists
|
|
if not g.user.two_factor_secret:
|
|
g.user.generate_2fa_secret()
|
|
db.session.commit()
|
|
|
|
# Generate QR code
|
|
import qrcode
|
|
import io
|
|
import base64
|
|
|
|
qr_uri = g.user.get_2fa_uri(issuer_name=g.branding.app_name)
|
|
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
|
qr.add_data(qr_uri)
|
|
qr.make(fit=True)
|
|
|
|
# Create QR code image
|
|
qr_img = qr.make_image(fill_color="black", back_color="white")
|
|
img_buffer = io.BytesIO()
|
|
qr_img.save(img_buffer, format='PNG')
|
|
img_buffer.seek(0)
|
|
qr_code_b64 = base64.b64encode(img_buffer.getvalue()).decode()
|
|
|
|
return render_template('setup_2fa.html',
|
|
title='Setup Two-Factor Authentication',
|
|
secret=g.user.two_factor_secret,
|
|
qr_code=qr_code_b64)
|
|
|
|
@app.route('/2fa/disable', methods=['POST'])
|
|
@login_required
|
|
def disable_2fa():
|
|
password = request.form.get('password')
|
|
|
|
if not password or not g.user.check_password(password):
|
|
flash('Please enter your correct password to disable 2FA.', 'error')
|
|
return redirect(url_for('profile'))
|
|
|
|
g.user.two_factor_enabled = False
|
|
g.user.two_factor_secret = None
|
|
db.session.commit()
|
|
|
|
flash('Two-factor authentication has been disabled.', 'success')
|
|
return redirect(url_for('profile'))
|
|
|
|
@app.route('/2fa/verify', methods=['GET', 'POST'])
|
|
def verify_2fa():
|
|
# Check if user is in 2FA verification state
|
|
user_id = session.get('2fa_user_id')
|
|
if not user_id:
|
|
return redirect(url_for('login'))
|
|
|
|
user = User.query.get(user_id)
|
|
if not user or not user.two_factor_enabled:
|
|
session.pop('2fa_user_id', None)
|
|
return redirect(url_for('login'))
|
|
|
|
if request.method == 'POST':
|
|
totp_code = request.form.get('totp_code')
|
|
|
|
if user.verify_2fa_token(totp_code):
|
|
# Complete login process
|
|
session.pop('2fa_user_id', None)
|
|
session['user_id'] = user.id
|
|
session['username'] = user.username
|
|
session['role'] = user.role.value
|
|
|
|
# Log successful 2FA login
|
|
SystemEvent.log_event(
|
|
'user_login_2fa',
|
|
f'User {user.username} logged in successfully with 2FA',
|
|
'auth',
|
|
'info',
|
|
user_id=user.id,
|
|
company_id=user.company_id,
|
|
ip_address=request.remote_addr,
|
|
user_agent=request.headers.get('User-Agent')
|
|
)
|
|
|
|
flash('Login successful!', 'success')
|
|
return redirect(url_for('home'))
|
|
else:
|
|
# Log failed 2FA attempt
|
|
SystemEvent.log_event(
|
|
'2fa_failed',
|
|
f'Failed 2FA verification for user {user.username}',
|
|
'auth',
|
|
'warning',
|
|
user_id=user.id,
|
|
company_id=user.company_id,
|
|
ip_address=request.remote_addr,
|
|
user_agent=request.headers.get('User-Agent')
|
|
)
|
|
|
|
flash('Invalid verification code. Please try again.', 'error')
|
|
|
|
return render_template('verify_2fa.html', title='Two-Factor Authentication')
|
|
|
|
@app.route('/about')
|
|
def about():
|
|
return render_template('about.html', title='About')
|
|
|
|
@app.route('/imprint')
|
|
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',
|
|
title=title,
|
|
content=content)
|
|
|
|
@app.route('/contact', methods=['GET', 'POST'])
|
|
@login_required
|
|
def contact():
|
|
# redacted
|
|
return render_template('contact.html', title='Contact')
|
|
|
|
|
|
# Notes Management Routes
|
|
@app.route('/notes')
|
|
@login_required
|
|
@company_required
|
|
def notes_list():
|
|
"""List all notes accessible to the user"""
|
|
# Get filter parameters
|
|
visibility_filter = request.args.get('visibility', 'all')
|
|
tag_filter = request.args.get('tag')
|
|
folder_filter = request.args.get('folder')
|
|
search_query = request.args.get('search', request.args.get('q'))
|
|
|
|
# Base query - all notes in user's company
|
|
query = Note.query.filter_by(company_id=g.user.company_id, is_archived=False)
|
|
|
|
# Apply visibility filter
|
|
if visibility_filter == 'private':
|
|
query = query.filter_by(created_by_id=g.user.id, visibility=NoteVisibility.PRIVATE)
|
|
elif visibility_filter == 'team':
|
|
query = query.filter_by(visibility=NoteVisibility.TEAM)
|
|
if g.user.role not in [Role.ADMIN, Role.SYSTEM_ADMIN]:
|
|
query = query.filter_by(team_id=g.user.team_id)
|
|
elif visibility_filter == 'company':
|
|
query = query.filter_by(visibility=NoteVisibility.COMPANY)
|
|
else: # 'all' - show all accessible notes
|
|
# Complex filter for visibility
|
|
from sqlalchemy import or_, and_
|
|
conditions = [
|
|
# User's own notes
|
|
Note.created_by_id == g.user.id,
|
|
# Company-wide notes
|
|
Note.visibility == NoteVisibility.COMPANY,
|
|
# Team notes if user is in the team
|
|
and_(
|
|
Note.visibility == NoteVisibility.TEAM,
|
|
Note.team_id == g.user.team_id
|
|
)
|
|
]
|
|
# Admins can see all team notes
|
|
if g.user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
|
|
conditions.append(Note.visibility == NoteVisibility.TEAM)
|
|
|
|
query = query.filter(or_(*conditions))
|
|
|
|
# Apply tag filter
|
|
if tag_filter:
|
|
query = query.filter(Note.tags.like(f'%{tag_filter}%'))
|
|
|
|
# Apply folder filter
|
|
if folder_filter:
|
|
query = query.filter_by(folder=folder_filter)
|
|
|
|
# Apply search
|
|
if search_query:
|
|
query = query.filter(
|
|
db.or_(
|
|
Note.title.ilike(f'%{search_query}%'),
|
|
Note.content.ilike(f'%{search_query}%'),
|
|
Note.tags.ilike(f'%{search_query}%')
|
|
)
|
|
)
|
|
|
|
# Order by pinned first, then by updated date
|
|
notes = query.order_by(Note.is_pinned.desc(), Note.updated_at.desc()).all()
|
|
|
|
# Get all unique tags for filter dropdown and count them
|
|
all_tags = set()
|
|
tag_counts = {}
|
|
visibility_counts = {'private': 0, 'team': 0, 'company': 0}
|
|
|
|
for note in Note.query.filter_by(company_id=g.user.company_id, is_archived=False).all():
|
|
if note.can_user_view(g.user):
|
|
# Count tags
|
|
note_tags = note.get_tags_list()
|
|
all_tags.update(note_tags)
|
|
for tag in note_tags:
|
|
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
|
|
|
# Count visibility
|
|
visibility_counts[note.visibility.value.lower()] = visibility_counts.get(note.visibility.value.lower(), 0) + 1
|
|
|
|
# Get all unique folders for filter dropdown
|
|
all_folders = set()
|
|
|
|
# Get folders from NoteFolder table
|
|
folder_records = NoteFolder.query.filter_by(company_id=g.user.company_id).all()
|
|
for folder in folder_records:
|
|
all_folders.add(folder.path)
|
|
|
|
# Also get folders from notes (for backward compatibility)
|
|
folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(Note.folder != None).all()
|
|
for note in folder_notes:
|
|
if note.folder:
|
|
all_folders.add(note.folder)
|
|
|
|
# Get projects for filter
|
|
projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all()
|
|
|
|
# Build folder tree structure for sidebar
|
|
folder_counts = {}
|
|
for note in Note.query.filter_by(company_id=g.user.company_id, is_archived=False).all():
|
|
if note.folder and note.can_user_view(g.user):
|
|
# Add this folder and all parent folders
|
|
parts = note.folder.split('/')
|
|
for i in range(len(parts)):
|
|
folder_path = '/'.join(parts[:i+1])
|
|
folder_counts[folder_path] = folder_counts.get(folder_path, 0) + (1 if i == len(parts)-1 else 0)
|
|
|
|
# Initialize counts for empty folders
|
|
for folder_path in all_folders:
|
|
if folder_path not in folder_counts:
|
|
folder_counts[folder_path] = 0
|
|
|
|
# Build folder tree structure
|
|
folder_tree = {}
|
|
for folder in sorted(all_folders):
|
|
parts = folder.split('/')
|
|
current = folder_tree
|
|
|
|
for i, part in enumerate(parts):
|
|
if i == len(parts) - 1:
|
|
# Leaf folder
|
|
current[folder] = {}
|
|
else:
|
|
# Navigate to parent
|
|
parent_path = '/'.join(parts[:i+1])
|
|
if parent_path not in current:
|
|
current[parent_path] = {}
|
|
current = current[parent_path]
|
|
|
|
return render_template('notes_list.html',
|
|
title='Notes',
|
|
notes=notes,
|
|
visibility_filter=visibility_filter,
|
|
tag_filter=tag_filter,
|
|
folder_filter=folder_filter,
|
|
search_query=search_query,
|
|
all_tags=sorted(list(all_tags)),
|
|
all_folders=sorted(list(all_folders)),
|
|
folder_tree=folder_tree,
|
|
folder_counts=folder_counts,
|
|
tag_counts=tag_counts,
|
|
visibility_counts=visibility_counts,
|
|
projects=projects,
|
|
NoteVisibility=NoteVisibility)
|
|
|
|
|
|
@app.route('/notes/new', methods=['GET', 'POST'])
|
|
@login_required
|
|
@company_required
|
|
def create_note():
|
|
"""Create a new note"""
|
|
if request.method == 'POST':
|
|
title = request.form.get('title', '').strip()
|
|
content = request.form.get('content', '').strip()
|
|
visibility = request.form.get('visibility', 'Private')
|
|
folder = request.form.get('folder', '').strip()
|
|
tags = request.form.get('tags', '').strip()
|
|
project_id = request.form.get('project_id')
|
|
task_id = request.form.get('task_id')
|
|
|
|
# Validate
|
|
if not title:
|
|
flash('Title is required', 'error')
|
|
return redirect(url_for('create_note'))
|
|
|
|
if not content:
|
|
flash('Content is required', 'error')
|
|
return redirect(url_for('create_note'))
|
|
|
|
try:
|
|
# Parse tags
|
|
tag_list = []
|
|
if tags:
|
|
tag_list = [tag.strip() for tag in tags.split(',') if tag.strip()]
|
|
|
|
# Create note
|
|
note = Note(
|
|
title=title,
|
|
content=content,
|
|
visibility=NoteVisibility[visibility.upper()], # Convert to uppercase for enum access
|
|
folder=folder if folder else None,
|
|
tags=','.join(tag_list) if tag_list else None,
|
|
created_by_id=g.user.id,
|
|
company_id=g.user.company_id
|
|
)
|
|
|
|
# Set team_id if visibility is Team
|
|
if visibility == 'Team' and g.user.team_id:
|
|
note.team_id = g.user.team_id
|
|
|
|
# Set optional associations
|
|
if project_id:
|
|
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first()
|
|
if project:
|
|
note.project_id = project.id
|
|
|
|
if task_id:
|
|
task = Task.query.filter_by(id=task_id).first()
|
|
if task and task.project.company_id == g.user.company_id:
|
|
note.task_id = task.id
|
|
|
|
# Generate slug
|
|
note.slug = note.generate_slug()
|
|
|
|
db.session.add(note)
|
|
db.session.commit()
|
|
|
|
flash('Note created successfully', 'success')
|
|
return redirect(url_for('view_note', slug=note.slug))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error creating note: {str(e)}")
|
|
flash('Error creating note', 'error')
|
|
return redirect(url_for('create_note'))
|
|
|
|
# GET request - show form
|
|
projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all()
|
|
tasks = []
|
|
|
|
# Get all existing folders for suggestions
|
|
all_folders = set()
|
|
|
|
# Get folders from NoteFolder table
|
|
folder_records = NoteFolder.query.filter_by(company_id=g.user.company_id).all()
|
|
for folder in folder_records:
|
|
all_folders.add(folder.path)
|
|
|
|
# Also get folders from notes (for backward compatibility)
|
|
folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(Note.folder != None).all()
|
|
for note in folder_notes:
|
|
if note.folder:
|
|
all_folders.add(note.folder)
|
|
|
|
return render_template('note_editor.html',
|
|
title='New Note',
|
|
note=None,
|
|
projects=projects,
|
|
tasks=tasks,
|
|
all_folders=sorted(list(all_folders)),
|
|
NoteVisibility=NoteVisibility)
|
|
|
|
|
|
@app.route('/notes/<slug>')
|
|
@login_required
|
|
@company_required
|
|
def view_note(slug):
|
|
"""View a specific note"""
|
|
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
|
|
|
|
# Check permissions
|
|
if not note.can_user_view(g.user):
|
|
abort(403)
|
|
|
|
# Get linked notes
|
|
outgoing_links = []
|
|
incoming_links = []
|
|
|
|
for link in note.outgoing_links:
|
|
if link.target_note.can_user_view(g.user):
|
|
outgoing_links.append(link)
|
|
|
|
for link in note.incoming_links:
|
|
if link.source_note.can_user_view(g.user):
|
|
incoming_links.append(link)
|
|
|
|
# Get linkable notes for the modal
|
|
linkable_notes = []
|
|
if note.can_user_edit(g.user):
|
|
# Get all notes the user can view
|
|
all_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).all()
|
|
for n in all_notes:
|
|
if n.id != note.id and n.can_user_view(g.user):
|
|
# Check if not already linked
|
|
already_linked = any(link.target_note_id == n.id for link in note.outgoing_links)
|
|
already_linked = already_linked or any(link.source_note_id == n.id for link in note.incoming_links)
|
|
if not already_linked:
|
|
linkable_notes.append(n)
|
|
|
|
return render_template('note_view.html',
|
|
title=note.title,
|
|
note=note,
|
|
outgoing_links=outgoing_links,
|
|
incoming_links=incoming_links,
|
|
linkable_notes=linkable_notes,
|
|
can_edit=note.can_user_edit(g.user))
|
|
|
|
|
|
@app.route('/notes/<slug>/edit', methods=['GET', 'POST'])
|
|
@login_required
|
|
@company_required
|
|
def edit_note(slug):
|
|
"""Edit an existing note"""
|
|
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
|
|
|
|
# Check permissions
|
|
if not note.can_user_edit(g.user):
|
|
abort(403)
|
|
|
|
if request.method == 'POST':
|
|
title = request.form.get('title', '').strip()
|
|
content = request.form.get('content', '').strip()
|
|
visibility = request.form.get('visibility', 'Private')
|
|
folder = request.form.get('folder', '').strip()
|
|
tags = request.form.get('tags', '').strip()
|
|
project_id = request.form.get('project_id')
|
|
task_id = request.form.get('task_id')
|
|
|
|
# Validate
|
|
if not title:
|
|
flash('Title is required', 'error')
|
|
return redirect(url_for('edit_note', slug=slug))
|
|
|
|
if not content:
|
|
flash('Content is required', 'error')
|
|
return redirect(url_for('edit_note', slug=slug))
|
|
|
|
try:
|
|
# Parse tags
|
|
tag_list = []
|
|
if tags:
|
|
tag_list = [tag.strip() for tag in tags.split(',') if tag.strip()]
|
|
|
|
# Update note
|
|
note.title = title
|
|
note.content = content
|
|
note.visibility = NoteVisibility[visibility.upper()] # Convert to uppercase for enum access
|
|
note.folder = folder if folder else None
|
|
note.tags = ','.join(tag_list) if tag_list else None
|
|
|
|
# Update team_id if visibility is Team
|
|
if visibility == 'Team' and g.user.team_id:
|
|
note.team_id = g.user.team_id
|
|
else:
|
|
note.team_id = None
|
|
|
|
# Update optional associations
|
|
if project_id:
|
|
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first()
|
|
note.project_id = project.id if project else None
|
|
else:
|
|
note.project_id = None
|
|
|
|
if task_id:
|
|
task = Task.query.filter_by(id=task_id).first()
|
|
if task and task.project.company_id == g.user.company_id:
|
|
note.task_id = task.id
|
|
else:
|
|
note.task_id = None
|
|
else:
|
|
note.task_id = None
|
|
|
|
# Regenerate slug if title changed
|
|
new_slug = note.generate_slug()
|
|
if new_slug != note.slug:
|
|
note.slug = new_slug
|
|
|
|
db.session.commit()
|
|
|
|
flash('Note updated successfully', 'success')
|
|
return redirect(url_for('view_note', slug=note.slug))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error updating note: {str(e)}")
|
|
flash('Error updating note', 'error')
|
|
return redirect(url_for('edit_note', slug=slug))
|
|
|
|
# GET request - show form
|
|
projects = Project.query.filter_by(company_id=g.user.company_id, is_active=True).all()
|
|
tasks = []
|
|
if note.project_id:
|
|
tasks = Task.query.filter_by(project_id=note.project_id).all()
|
|
|
|
# Get all existing folders for suggestions
|
|
all_folders = set()
|
|
|
|
# Get folders from NoteFolder table
|
|
folder_records = NoteFolder.query.filter_by(company_id=g.user.company_id).all()
|
|
for folder in folder_records:
|
|
all_folders.add(folder.path)
|
|
|
|
# Also get folders from notes (for backward compatibility)
|
|
folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(Note.folder != None).all()
|
|
for n in folder_notes:
|
|
if n.folder:
|
|
all_folders.add(n.folder)
|
|
|
|
return render_template('note_editor.html',
|
|
title=f'Edit: {note.title}',
|
|
note=note,
|
|
projects=projects,
|
|
tasks=tasks,
|
|
all_folders=sorted(list(all_folders)),
|
|
NoteVisibility=NoteVisibility)
|
|
|
|
|
|
@app.route('/notes/<slug>/delete', methods=['POST'])
|
|
@login_required
|
|
@company_required
|
|
def delete_note(slug):
|
|
"""Delete (archive) a note"""
|
|
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
|
|
|
|
# Check permissions
|
|
if not note.can_user_edit(g.user):
|
|
abort(403)
|
|
|
|
try:
|
|
# Soft delete
|
|
note.is_archived = True
|
|
note.archived_at = datetime.now()
|
|
db.session.commit()
|
|
|
|
flash('Note deleted successfully', 'success')
|
|
return redirect(url_for('notes_list'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error deleting note: {str(e)}")
|
|
flash('Error deleting note', 'error')
|
|
return redirect(url_for('view_note', slug=slug))
|
|
|
|
|
|
@app.route('/notes/folders')
|
|
@login_required
|
|
@company_required
|
|
def notes_folders():
|
|
"""Manage note folders"""
|
|
# Get all folders from NoteFolder table
|
|
all_folders = set()
|
|
folder_records = NoteFolder.query.filter_by(company_id=g.user.company_id).all()
|
|
|
|
for folder in folder_records:
|
|
all_folders.add(folder.path)
|
|
|
|
# Also get folders from notes (for backward compatibility)
|
|
folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(Note.folder != None).all()
|
|
|
|
folder_counts = {}
|
|
for note in folder_notes:
|
|
if note.folder and note.can_user_view(g.user):
|
|
# Add this folder and all parent folders
|
|
parts = note.folder.split('/')
|
|
for i in range(len(parts)):
|
|
folder_path = '/'.join(parts[:i+1])
|
|
all_folders.add(folder_path)
|
|
folder_counts[folder_path] = folder_counts.get(folder_path, 0) + (1 if i == len(parts)-1 else 0)
|
|
|
|
# Initialize counts for empty folders
|
|
for folder_path in all_folders:
|
|
if folder_path not in folder_counts:
|
|
folder_counts[folder_path] = 0
|
|
|
|
# Build folder tree structure
|
|
folder_tree = {}
|
|
for folder in sorted(all_folders):
|
|
parts = folder.split('/')
|
|
current = folder_tree
|
|
|
|
for i, part in enumerate(parts):
|
|
if i == len(parts) - 1:
|
|
# Leaf folder
|
|
current[folder] = {}
|
|
else:
|
|
# Navigate to parent
|
|
parent_path = '/'.join(parts[:i+1])
|
|
if parent_path not in current:
|
|
current[parent_path] = {}
|
|
current = current[parent_path]
|
|
|
|
return render_template('notes_folders.html',
|
|
title='Note Folders',
|
|
all_folders=sorted(list(all_folders)),
|
|
folder_tree=folder_tree,
|
|
folder_counts=folder_counts)
|
|
|
|
|
|
@app.route('/api/notes/folder-details')
|
|
@login_required
|
|
@company_required
|
|
def api_folder_details():
|
|
"""Get details about a specific folder"""
|
|
folder_path = request.args.get('path', '')
|
|
|
|
if not folder_path:
|
|
return jsonify({'error': 'Folder path required'}), 400
|
|
|
|
# Get notes in this folder
|
|
notes = Note.query.filter_by(
|
|
company_id=g.user.company_id,
|
|
folder=folder_path,
|
|
is_archived=False
|
|
).all()
|
|
|
|
# Filter by visibility
|
|
visible_notes = [n for n in notes if n.can_user_view(g.user)]
|
|
|
|
# Get subfolders
|
|
all_folders = set()
|
|
folder_notes = Note.query.filter_by(company_id=g.user.company_id, is_archived=False).filter(
|
|
Note.folder.like(f'{folder_path}/%')
|
|
).all()
|
|
|
|
for note in folder_notes:
|
|
if note.folder and note.can_user_view(g.user):
|
|
# Get immediate subfolder
|
|
subfolder = note.folder[len(folder_path)+1:]
|
|
if '/' in subfolder:
|
|
subfolder = subfolder.split('/')[0]
|
|
all_folders.add(subfolder)
|
|
|
|
# Get recent notes (last 5)
|
|
recent_notes = sorted(visible_notes, key=lambda n: n.updated_at, reverse=True)[:5]
|
|
|
|
return jsonify({
|
|
'name': folder_path.split('/')[-1],
|
|
'path': folder_path,
|
|
'note_count': len(visible_notes),
|
|
'subfolder_count': len(all_folders),
|
|
'recent_notes': [
|
|
{
|
|
'title': n.title,
|
|
'slug': n.slug,
|
|
'updated_at': n.updated_at.strftime('%Y-%m-%d %H:%M')
|
|
} for n in recent_notes
|
|
]
|
|
})
|
|
|
|
|
|
@app.route('/api/notes/folders', methods=['POST'])
|
|
@login_required
|
|
@company_required
|
|
def api_create_folder():
|
|
"""Create a new folder"""
|
|
data = request.get_json()
|
|
folder_name = data.get('name', '').strip()
|
|
parent_folder = data.get('parent', '').strip()
|
|
|
|
if not folder_name:
|
|
return jsonify({'success': False, 'message': 'Folder name is required'}), 400
|
|
|
|
# Validate folder name (no special characters except dash and underscore)
|
|
import re
|
|
if not re.match(r'^[a-zA-Z0-9_\- ]+$', folder_name):
|
|
return jsonify({'success': False, 'message': 'Folder name can only contain letters, numbers, spaces, dashes, and underscores'}), 400
|
|
|
|
# Create full path
|
|
full_path = f"{parent_folder}/{folder_name}" if parent_folder else folder_name
|
|
|
|
# Check if folder already exists
|
|
existing_folder = NoteFolder.query.filter_by(
|
|
company_id=g.user.company_id,
|
|
path=full_path
|
|
).first()
|
|
|
|
if existing_folder:
|
|
return jsonify({'success': False, 'message': 'Folder already exists'}), 400
|
|
|
|
# Create the folder
|
|
try:
|
|
folder = NoteFolder(
|
|
name=folder_name,
|
|
path=full_path,
|
|
parent_path=parent_folder if parent_folder else None,
|
|
description=data.get('description', ''),
|
|
created_by_id=g.user.id,
|
|
company_id=g.user.company_id
|
|
)
|
|
|
|
db.session.add(folder)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Folder created successfully',
|
|
'folder': {
|
|
'name': folder_name,
|
|
'path': full_path
|
|
}
|
|
})
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error creating folder: {str(e)}")
|
|
return jsonify({'success': False, 'message': 'Error creating folder'}), 500
|
|
|
|
|
|
@app.route('/api/notes/folders', methods=['PUT'])
|
|
@login_required
|
|
@company_required
|
|
def api_rename_folder():
|
|
"""Rename an existing folder"""
|
|
data = request.get_json()
|
|
old_path = data.get('old_path', '').strip()
|
|
new_name = data.get('new_name', '').strip()
|
|
|
|
if not old_path or not new_name:
|
|
return jsonify({'success': False, 'message': 'Old path and new name are required'}), 400
|
|
|
|
# Validate folder name
|
|
import re
|
|
if not re.match(r'^[a-zA-Z0-9_\- ]+$', new_name):
|
|
return jsonify({'success': False, 'message': 'Folder name can only contain letters, numbers, spaces, dashes, and underscores'}), 400
|
|
|
|
# Build new path
|
|
path_parts = old_path.split('/')
|
|
path_parts[-1] = new_name
|
|
new_path = '/'.join(path_parts)
|
|
|
|
# Update all notes in this folder and subfolders
|
|
notes_to_update = Note.query.filter(
|
|
Note.company_id == g.user.company_id,
|
|
db.or_(
|
|
Note.folder == old_path,
|
|
Note.folder.like(f'{old_path}/%')
|
|
)
|
|
).all()
|
|
|
|
# Check permissions for all notes
|
|
for note in notes_to_update:
|
|
if not note.can_user_edit(g.user):
|
|
return jsonify({'success': False, 'message': 'You do not have permission to modify all notes in this folder'}), 403
|
|
|
|
# Update folder paths
|
|
try:
|
|
for note in notes_to_update:
|
|
if note.folder == old_path:
|
|
note.folder = new_path
|
|
else:
|
|
# Update subfolder path
|
|
note.folder = new_path + note.folder[len(old_path):]
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Renamed folder to {new_name}',
|
|
'updated_count': len(notes_to_update)
|
|
})
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error renaming folder: {str(e)}")
|
|
return jsonify({'success': False, 'message': 'Error renaming folder'}), 500
|
|
|
|
|
|
@app.route('/api/notes/folders', methods=['DELETE'])
|
|
@login_required
|
|
@company_required
|
|
def api_delete_folder():
|
|
"""Delete an empty folder"""
|
|
folder_path = request.args.get('path', '').strip()
|
|
|
|
if not folder_path:
|
|
return jsonify({'success': False, 'message': 'Folder path is required'}), 400
|
|
|
|
# Check if folder has any notes
|
|
notes_in_folder = Note.query.filter_by(
|
|
company_id=g.user.company_id,
|
|
folder=folder_path,
|
|
is_archived=False
|
|
).all()
|
|
|
|
if notes_in_folder:
|
|
return jsonify({'success': False, 'message': 'Cannot delete folder that contains notes'}), 400
|
|
|
|
# Check if folder has subfolders with notes
|
|
notes_in_subfolders = Note.query.filter(
|
|
Note.company_id == g.user.company_id,
|
|
Note.folder.like(f'{folder_path}/%'),
|
|
Note.is_archived == False
|
|
).first()
|
|
|
|
if notes_in_subfolders:
|
|
return jsonify({'success': False, 'message': 'Cannot delete folder that contains subfolders with notes'}), 400
|
|
|
|
# Since we don't have a separate folders table, we just return success
|
|
# The folder will disappear from the UI when there are no notes in it
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Folder deleted successfully'
|
|
})
|
|
|
|
|
|
@app.route('/api/notes/<slug>/folder', methods=['PUT'])
|
|
@login_required
|
|
@company_required
|
|
def update_note_folder(slug):
|
|
"""Update a note's folder via drag and drop"""
|
|
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
|
|
|
|
# Check permissions
|
|
if not note.can_user_edit(g.user):
|
|
return jsonify({'success': False, 'message': 'Permission denied'}), 403
|
|
|
|
data = request.get_json()
|
|
folder_path = data.get('folder', '').strip()
|
|
|
|
try:
|
|
# Update the note's folder
|
|
note.folder = folder_path if folder_path else None
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Note moved successfully',
|
|
'folder': folder_path
|
|
})
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error updating note folder: {str(e)}")
|
|
return jsonify({'success': False, 'message': 'Error updating note folder'}), 500
|
|
|
|
|
|
@app.route('/api/notes/<int:note_id>/tags', methods=['POST'])
|
|
@login_required
|
|
@company_required
|
|
def add_tags_to_note(note_id):
|
|
"""Add tags to a note"""
|
|
note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first_or_404()
|
|
|
|
# Check permissions
|
|
if not note.can_user_edit(g.user):
|
|
return jsonify({'success': False, 'message': 'Permission denied'}), 403
|
|
|
|
data = request.get_json()
|
|
new_tags = data.get('tags', '').strip()
|
|
|
|
if not new_tags:
|
|
return jsonify({'success': False, 'message': 'No tags provided'}), 400
|
|
|
|
try:
|
|
# Get existing tags
|
|
existing_tags = note.get_tags_list()
|
|
|
|
# Parse new tags
|
|
new_tag_list = [tag.strip() for tag in new_tags.split(',') if tag.strip()]
|
|
|
|
# Merge tags (avoid duplicates)
|
|
all_tags = list(set(existing_tags + new_tag_list))
|
|
|
|
# Update note
|
|
note.set_tags_list(all_tags)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Tags added successfully',
|
|
'tags': note.tags
|
|
})
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error adding tags to note: {str(e)}")
|
|
return jsonify({'success': False, 'message': 'Error adding tags'}), 500
|
|
|
|
|
|
@app.route('/api/notes/<int:note_id>/link', methods=['POST'])
|
|
@login_required
|
|
@company_required
|
|
def link_notes(note_id):
|
|
"""Create a link between two notes"""
|
|
source_note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first_or_404()
|
|
|
|
# Check permissions
|
|
if not source_note.can_user_edit(g.user):
|
|
return jsonify({'success': False, 'message': 'Permission denied'}), 403
|
|
|
|
data = request.get_json()
|
|
target_note_id = data.get('target_note_id')
|
|
link_type = data.get('link_type', 'related')
|
|
|
|
if not target_note_id:
|
|
return jsonify({'success': False, 'message': 'Target note ID required'}), 400
|
|
|
|
target_note = Note.query.filter_by(id=target_note_id, company_id=g.user.company_id).first()
|
|
if not target_note:
|
|
return jsonify({'success': False, 'message': 'Target note not found'}), 404
|
|
|
|
if not target_note.can_user_view(g.user):
|
|
return jsonify({'success': False, 'message': 'Cannot link to this note'}), 403
|
|
|
|
try:
|
|
# Check if link already exists (in either direction)
|
|
existing_link = NoteLink.query.filter(
|
|
db.or_(
|
|
db.and_(
|
|
NoteLink.source_note_id == note_id,
|
|
NoteLink.target_note_id == target_note_id
|
|
),
|
|
db.and_(
|
|
NoteLink.source_note_id == target_note_id,
|
|
NoteLink.target_note_id == note_id
|
|
)
|
|
)
|
|
).first()
|
|
|
|
if existing_link:
|
|
return jsonify({'success': False, 'message': 'Link already exists between these notes'}), 400
|
|
|
|
# Create link
|
|
link = NoteLink(
|
|
source_note_id=note_id,
|
|
target_note_id=target_note_id,
|
|
link_type=link_type,
|
|
created_by_id=g.user.id
|
|
)
|
|
|
|
db.session.add(link)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Notes linked successfully',
|
|
'link': {
|
|
'id': link.id,
|
|
'source_note_id': link.source_note_id,
|
|
'target_note_id': link.target_note_id,
|
|
'target_note': {
|
|
'id': target_note.id,
|
|
'title': target_note.title,
|
|
'slug': target_note.slug
|
|
}
|
|
}
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error linking notes: {str(e)}")
|
|
return jsonify({'success': False, 'message': f'Error linking notes: {str(e)}'}), 500
|
|
|
|
# We can keep this route as a redirect to home for backward compatibility
|
|
@app.route('/timetrack')
|
|
@login_required
|
|
def timetrack():
|
|
return redirect(url_for('home'))
|
|
|
|
@app.route('/api/arrive', methods=['POST'])
|
|
@login_required
|
|
def arrive():
|
|
# Get project and notes from request
|
|
project_id = request.json.get('project_id') if request.json else None
|
|
notes = request.json.get('notes') if request.json else None
|
|
|
|
# Validate project access if project is specified
|
|
if project_id:
|
|
project = Project.query.get(project_id)
|
|
if not project or not project.is_user_allowed(g.user):
|
|
return jsonify({'error': 'Invalid or unauthorized project'}), 403
|
|
|
|
# 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,
|
|
notes=notes
|
|
)
|
|
db.session.add(new_entry)
|
|
db.session.commit()
|
|
|
|
# Format response with user preferences
|
|
from time_utils import format_datetime_by_preference, get_user_format_settings
|
|
date_format, time_format_24h = get_user_format_settings(g.user)
|
|
|
|
return jsonify({
|
|
'id': new_entry.id,
|
|
'arrival_time': format_datetime_by_preference(new_entry.arrival_time, date_format, time_format_24h),
|
|
'project': {
|
|
'id': new_entry.project.id,
|
|
'code': new_entry.project.code,
|
|
'name': new_entry.project.name
|
|
} if new_entry.project else None,
|
|
'notes': new_entry.notes
|
|
})
|
|
|
|
@app.route('/api/leave/<int:entry_id>', methods=['POST'])
|
|
@login_required
|
|
def leave(entry_id):
|
|
# Find the time entry for the current user
|
|
entry = TimeEntry.query.filter_by(id=entry_id, user_id=session['user_id']).first_or_404()
|
|
|
|
# Set the departure time
|
|
departure_time = datetime.now()
|
|
|
|
# Apply time rounding if enabled
|
|
rounded_arrival, rounded_departure = apply_time_rounding(entry.arrival_time, departure_time, g.user)
|
|
entry.arrival_time = rounded_arrival
|
|
entry.departure_time = rounded_departure
|
|
|
|
# If currently paused, add the final break duration
|
|
if entry.is_paused and entry.pause_start_time:
|
|
final_break_duration = int((rounded_departure - entry.pause_start_time).total_seconds())
|
|
entry.total_break_duration += final_break_duration
|
|
entry.is_paused = False
|
|
entry.pause_start_time = None
|
|
|
|
# Apply rounding to break duration if enabled
|
|
interval_minutes, round_to_nearest = get_user_rounding_settings(g.user)
|
|
if interval_minutes > 0:
|
|
entry.total_break_duration = round_duration_to_interval(
|
|
entry.total_break_duration, interval_minutes, round_to_nearest
|
|
)
|
|
|
|
# Calculate work duration considering breaks
|
|
entry.duration, effective_break = calculate_work_duration(
|
|
rounded_arrival,
|
|
rounded_departure,
|
|
entry.total_break_duration,
|
|
g.user
|
|
)
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'id': entry.id,
|
|
'arrival_time': entry.arrival_time.isoformat(),
|
|
'departure_time': entry.departure_time.isoformat(),
|
|
'duration': entry.duration,
|
|
'total_break_duration': entry.total_break_duration,
|
|
'effective_break_duration': effective_break
|
|
})
|
|
|
|
# Add this new route to handle pausing/resuming
|
|
@app.route('/api/toggle-pause/<int:entry_id>', methods=['POST'])
|
|
@login_required
|
|
def toggle_pause(entry_id):
|
|
# Find the time entry for the current user
|
|
entry = TimeEntry.query.filter_by(id=entry_id, user_id=session['user_id']).first_or_404()
|
|
|
|
now = datetime.now()
|
|
|
|
if entry.is_paused:
|
|
# Resuming work - calculate break duration
|
|
break_duration = int((now - entry.pause_start_time).total_seconds())
|
|
entry.total_break_duration += break_duration
|
|
entry.is_paused = False
|
|
entry.pause_start_time = None
|
|
|
|
message = "Work resumed"
|
|
else:
|
|
# Pausing work
|
|
entry.is_paused = True
|
|
entry.pause_start_time = now
|
|
|
|
message = "Work paused"
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'id': entry.id,
|
|
'is_paused': entry.is_paused,
|
|
'total_break_duration': entry.total_break_duration,
|
|
'message': message
|
|
})
|
|
|
|
@app.route('/config', methods=['GET', 'POST'])
|
|
@login_required
|
|
def config():
|
|
# Get user preferences or create default if none exists
|
|
preferences = UserPreferences.query.filter_by(user_id=g.user.id).first()
|
|
if not preferences:
|
|
preferences = UserPreferences(user_id=g.user.id)
|
|
db.session.add(preferences)
|
|
db.session.commit()
|
|
|
|
if request.method == 'POST':
|
|
try:
|
|
# Update only user preferences (no company policies)
|
|
preferences.time_format_24h = 'time_format_24h' in request.form
|
|
preferences.date_format = request.form.get('date_format', 'ISO')
|
|
preferences.time_rounding_minutes = int(request.form.get('time_rounding_minutes', 0))
|
|
preferences.round_to_nearest = 'round_to_nearest' in request.form
|
|
|
|
db.session.commit()
|
|
flash('Preferences updated successfully!', 'success')
|
|
return redirect(url_for('config'))
|
|
except ValueError:
|
|
flash('Please enter valid values for all fields', 'error')
|
|
|
|
# Get company work policies for display (read-only)
|
|
company_config = CompanyWorkConfig.query.filter_by(company_id=g.user.company_id).first()
|
|
|
|
# Import time utils for display options
|
|
from time_utils import get_available_rounding_options, get_available_date_formats
|
|
rounding_options = get_available_rounding_options()
|
|
date_format_options = get_available_date_formats()
|
|
|
|
return render_template('config.html', title='User Preferences',
|
|
preferences=preferences,
|
|
company_config=company_config,
|
|
rounding_options=rounding_options,
|
|
date_format_options=date_format_options)
|
|
|
|
@app.route('/api/delete/<int:entry_id>', methods=['DELETE'])
|
|
@login_required
|
|
def delete_entry(entry_id):
|
|
entry = TimeEntry.query.filter_by(id=entry_id, user_id=session['user_id']).first_or_404()
|
|
db.session.delete(entry)
|
|
db.session.commit()
|
|
return jsonify({'success': True, 'message': 'Entry deleted successfully'})
|
|
|
|
@app.route('/api/update/<int:entry_id>', methods=['PUT'])
|
|
@login_required
|
|
def update_entry(entry_id):
|
|
entry = TimeEntry.query.filter_by(id=entry_id, user_id=session['user_id']).first_or_404()
|
|
data = request.json
|
|
|
|
if not data:
|
|
return jsonify({'success': False, 'message': 'No JSON data provided'}), 400
|
|
|
|
if 'arrival_time' in data:
|
|
try:
|
|
# Accept only ISO 8601 format
|
|
arrival_time_str = data['arrival_time']
|
|
entry.arrival_time = datetime.fromisoformat(arrival_time_str.replace('Z', '+00:00'))
|
|
except (ValueError, AttributeError) as e:
|
|
return jsonify({'success': False, 'message': f'Invalid arrival time format. Expected ISO 8601: {arrival_time_str}'}), 400
|
|
|
|
if 'departure_time' in data and data['departure_time']:
|
|
try:
|
|
# Accept only ISO 8601 format
|
|
departure_time_str = data['departure_time']
|
|
entry.departure_time = datetime.fromisoformat(departure_time_str.replace('Z', '+00:00'))
|
|
|
|
# Recalculate duration if both times are present
|
|
if entry.arrival_time and entry.departure_time:
|
|
# Calculate work duration considering breaks
|
|
entry.duration, _ = calculate_work_duration(
|
|
entry.arrival_time,
|
|
entry.departure_time,
|
|
entry.total_break_duration,
|
|
g.user
|
|
)
|
|
except (ValueError, AttributeError) as e:
|
|
return jsonify({'success': False, 'message': f'Invalid departure time format. Expected ISO 8601: {departure_time_str}'}), 400
|
|
|
|
db.session.commit()
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Entry updated successfully',
|
|
'entry': {
|
|
'id': entry.id,
|
|
'arrival_time': entry.arrival_time.isoformat(),
|
|
'departure_time': entry.departure_time.isoformat() if entry.departure_time else None,
|
|
'duration': entry.duration,
|
|
'is_paused': entry.is_paused,
|
|
'total_break_duration': entry.total_break_duration
|
|
}
|
|
})
|
|
|
|
def calculate_work_duration(arrival_time, departure_time, total_break_duration, user):
|
|
"""
|
|
Calculate work duration considering both configured and actual break times.
|
|
|
|
Args:
|
|
arrival_time: Datetime of arrival
|
|
departure_time: Datetime of departure
|
|
total_break_duration: Actual logged break duration in seconds
|
|
user: User object to get company configuration
|
|
|
|
Returns:
|
|
tuple: (work_duration_in_seconds, effective_break_duration_in_seconds)
|
|
"""
|
|
# Calculate raw duration
|
|
raw_duration = (departure_time - arrival_time).total_seconds()
|
|
|
|
# Get company work configuration for break rules
|
|
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']
|
|
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
|
|
|
|
# Calculate mandatory breaks based on work duration
|
|
work_hours = raw_duration / 3600 # Convert seconds to hours
|
|
configured_break_seconds = 0
|
|
|
|
# Apply primary break if work duration exceeds threshold
|
|
if work_hours > break_threshold_hours:
|
|
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
|
|
|
|
# Use the greater of configured breaks or actual logged breaks
|
|
effective_break_duration = max(configured_break_seconds, total_break_duration)
|
|
|
|
# Calculate final work duration
|
|
work_duration = int(raw_duration - effective_break_duration)
|
|
|
|
return work_duration, effective_break_duration
|
|
|
|
@app.route('/api/resume/<int:entry_id>', methods=['POST'])
|
|
@login_required
|
|
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 there's already an active entry
|
|
active_entry = TimeEntry.query.filter_by(user_id=session['user_id'], departure_time=None).first()
|
|
if active_entry:
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'Cannot resume this entry. Another session is already active.'
|
|
}), 400
|
|
|
|
# Clear the departure time to make this entry active again
|
|
entry_to_resume.departure_time = None
|
|
|
|
# Reset pause state if it was paused
|
|
entry_to_resume.is_paused = False
|
|
entry_to_resume.pause_start_time = None
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Work resumed on existing entry',
|
|
'id': entry_to_resume.id,
|
|
'arrival_time': entry_to_resume.arrival_time.isoformat(),
|
|
'total_break_duration': entry_to_resume.total_break_duration
|
|
})
|
|
|
|
@app.route('/api/manual-entry', methods=['POST'])
|
|
@login_required
|
|
def manual_entry():
|
|
try:
|
|
data = request.get_json()
|
|
|
|
# Extract data from request
|
|
project_id = data.get('project_id')
|
|
start_date = data.get('start_date')
|
|
start_time = data.get('start_time')
|
|
end_date = data.get('end_date')
|
|
end_time = data.get('end_time')
|
|
break_minutes = int(data.get('break_minutes', 0))
|
|
notes = data.get('notes', '')
|
|
|
|
# Validate required fields
|
|
if not all([start_date, start_time, end_date, end_time]):
|
|
return jsonify({'error': 'Start and end date/time are required'}), 400
|
|
|
|
# Parse datetime strings
|
|
try:
|
|
arrival_datetime = datetime.strptime(f"{start_date} {start_time}", '%Y-%m-%d %H:%M:%S')
|
|
departure_datetime = datetime.strptime(f"{end_date} {end_time}", '%Y-%m-%d %H:%M:%S')
|
|
except ValueError:
|
|
try:
|
|
# Try without seconds if parsing fails
|
|
arrival_datetime = datetime.strptime(f"{start_date} {start_time}:00", '%Y-%m-%d %H:%M:%S')
|
|
departure_datetime = datetime.strptime(f"{end_date} {end_time}:00", '%Y-%m-%d %H:%M:%S')
|
|
except ValueError:
|
|
return jsonify({'error': 'Invalid date/time format'}), 400
|
|
|
|
# Validate that end time is after start time
|
|
if departure_datetime <= arrival_datetime:
|
|
return jsonify({'error': 'End time must be after start time'}), 400
|
|
|
|
# Apply time rounding if enabled
|
|
rounded_arrival, rounded_departure = apply_time_rounding(arrival_datetime, departure_datetime, g.user)
|
|
|
|
# Validate project access if project is specified
|
|
if project_id:
|
|
project = Project.query.get(project_id)
|
|
if not project or not project.is_user_allowed(g.user):
|
|
return jsonify({'error': 'Invalid or unauthorized project'}), 403
|
|
|
|
# Check for overlapping entries for this user (using rounded times)
|
|
overlapping_entry = TimeEntry.query.filter(
|
|
TimeEntry.user_id == g.user.id,
|
|
TimeEntry.departure_time.isnot(None),
|
|
TimeEntry.arrival_time < rounded_departure,
|
|
TimeEntry.departure_time > rounded_arrival
|
|
).first()
|
|
|
|
if overlapping_entry:
|
|
return jsonify({
|
|
'error': 'This time entry overlaps with an existing entry'
|
|
}), 400
|
|
|
|
# Calculate total duration in seconds (using rounded times)
|
|
total_duration = int((rounded_departure - rounded_arrival).total_seconds())
|
|
break_duration_seconds = break_minutes * 60
|
|
|
|
# Apply rounding to break duration if enabled
|
|
interval_minutes, round_to_nearest = get_user_rounding_settings(g.user)
|
|
if interval_minutes > 0:
|
|
break_duration_seconds = round_duration_to_interval(
|
|
break_duration_seconds, interval_minutes, round_to_nearest
|
|
)
|
|
|
|
# Validate break duration doesn't exceed total duration
|
|
if break_duration_seconds >= total_duration:
|
|
return jsonify({'error': 'Break duration cannot exceed total work duration'}), 400
|
|
|
|
# Calculate work duration (total duration minus breaks)
|
|
work_duration = total_duration - break_duration_seconds
|
|
|
|
# Create the manual time entry (using rounded times)
|
|
new_entry = TimeEntry(
|
|
user_id=g.user.id,
|
|
arrival_time=rounded_arrival,
|
|
departure_time=rounded_departure,
|
|
duration=work_duration,
|
|
total_break_duration=break_duration_seconds,
|
|
project_id=int(project_id) if project_id else None,
|
|
notes=notes,
|
|
is_paused=False,
|
|
pause_start_time=None
|
|
)
|
|
|
|
db.session.add(new_entry)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Manual time entry added successfully',
|
|
'entry_id': new_entry.id
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error creating manual time entry: {str(e)}")
|
|
db.session.rollback()
|
|
return jsonify({'error': 'An error occurred while creating the time entry'}), 500
|
|
|
|
@app.errorhandler(404)
|
|
def page_not_found(e):
|
|
return render_template('404.html'), 404
|
|
|
|
@app.errorhandler(500)
|
|
def internal_server_error(e):
|
|
return render_template('500.html'), 500
|
|
|
|
@app.route('/test')
|
|
def test():
|
|
return "App is working!"
|
|
|
|
@app.route('/admin/users/toggle-status/<int:user_id>')
|
|
@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/<int:user_id>/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/<int:user_id>/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/<int:company_id>')
|
|
@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)
|
|
|
|
@app.route('/system-admin/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_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)
|
|
|
|
@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)
|
|
|
|
@app.route('/system-admin/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
|
|
from datetime import datetime, timedelta
|
|
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)
|
|
|
|
@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/<int:id>/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/<int:id>/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/<int:team_id>', 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/<int:team_id>', 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/<int:team_id>', 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/<int:project_id>', 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/<int:project_id>', 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'))
|
|
|
|
|
|
@app.route('/analytics')
|
|
@app.route('/analytics/<mode>')
|
|
@login_required
|
|
def analytics(mode='personal'):
|
|
"""Unified analytics view combining history, team hours, and graphs"""
|
|
# Validate mode parameter
|
|
if mode not in ['personal', 'team']:
|
|
mode = 'personal'
|
|
|
|
# Check team access for team mode
|
|
if mode == 'team':
|
|
if not g.user.team_id:
|
|
flash('You must be assigned to a team to view team analytics.', 'warning')
|
|
return redirect(url_for('analytics', mode='personal'))
|
|
|
|
if g.user.role not in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN]:
|
|
flash('You do not have permission to view team analytics.', 'error')
|
|
return redirect(url_for('analytics', mode='personal'))
|
|
|
|
# Get available projects for filtering
|
|
available_projects = []
|
|
all_projects = Project.query.filter_by(is_active=True).all()
|
|
for project in all_projects:
|
|
if project.is_user_allowed(g.user):
|
|
available_projects.append(project)
|
|
|
|
# Get team members if in team mode
|
|
team_members = []
|
|
if mode == 'team' and g.user.team_id:
|
|
team_members = User.query.filter_by(team_id=g.user.team_id).all()
|
|
|
|
# Default date range (current week)
|
|
today = datetime.now().date()
|
|
start_of_week = today - timedelta(days=today.weekday())
|
|
end_of_week = start_of_week + timedelta(days=6)
|
|
|
|
return render_template('analytics.html',
|
|
title='Time Analytics',
|
|
mode=mode,
|
|
available_projects=available_projects,
|
|
team_members=team_members,
|
|
default_start_date=start_of_week.strftime('%Y-%m-%d'),
|
|
default_end_date=end_of_week.strftime('%Y-%m-%d'))
|
|
|
|
@app.route('/api/analytics/data')
|
|
@login_required
|
|
def analytics_data():
|
|
"""API endpoint for analytics data"""
|
|
mode = request.args.get('mode', 'personal')
|
|
view_type = request.args.get('view', 'table')
|
|
start_date = request.args.get('start_date')
|
|
end_date = request.args.get('end_date')
|
|
project_filter = request.args.get('project_id')
|
|
granularity = request.args.get('granularity', 'daily')
|
|
|
|
# Validate mode
|
|
if mode not in ['personal', 'team']:
|
|
return jsonify({'error': 'Invalid mode'}), 400
|
|
|
|
# Check permissions for team mode
|
|
if mode == 'team':
|
|
if not g.user.team_id:
|
|
return jsonify({'error': 'No team assigned'}), 403
|
|
if g.user.role not in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN]:
|
|
return jsonify({'error': 'Insufficient permissions'}), 403
|
|
|
|
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 filtered data
|
|
data = get_filtered_analytics_data(g.user, mode, start_date, end_date, project_filter)
|
|
|
|
# Format data based on view type
|
|
if view_type == 'graph':
|
|
formatted_data = format_graph_data(data, granularity)
|
|
# For burndown chart, we need task data instead of time entries
|
|
chart_type = request.args.get('chart_type', 'timeSeries')
|
|
if chart_type == 'burndown':
|
|
# Get tasks for burndown chart
|
|
tasks = get_filtered_tasks_for_burndown(g.user, mode, start_date, end_date, project_filter)
|
|
burndown_data = format_burndown_data(tasks, start_date, end_date)
|
|
formatted_data.update(burndown_data)
|
|
elif view_type == 'team':
|
|
formatted_data = format_team_data(data, granularity)
|
|
else:
|
|
formatted_data = format_table_data(data)
|
|
|
|
return jsonify(formatted_data)
|
|
|
|
except Exception as e:
|
|
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
|
|
|
|
# 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/<int:company_id>/teams')
|
|
@system_admin_required
|
|
def api_company_teams(company_id):
|
|
"""API: Get teams for a specific company (System Admin only)"""
|
|
teams = Team.query.filter_by(company_id=company_id).order_by(Team.name).all()
|
|
return jsonify([{
|
|
'id': team.id,
|
|
'name': team.name,
|
|
'description': team.description
|
|
} for team in teams])
|
|
|
|
@app.route('/api/system-admin/stats')
|
|
@system_admin_required
|
|
def api_system_admin_stats():
|
|
"""API: Get real-time system statistics for dashboard"""
|
|
from datetime import datetime, timedelta
|
|
|
|
# Get basic counts
|
|
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()
|
|
|
|
# Active sessions
|
|
active_sessions = TimeEntry.query.filter_by(departure_time=None, is_paused=False).count()
|
|
paused_sessions = TimeEntry.query.filter_by(is_paused=True).count()
|
|
|
|
# Recent activity (last 24 hours)
|
|
yesterday = datetime.now() - timedelta(days=1)
|
|
recent_users = User.query.filter(User.created_at >= yesterday).count()
|
|
recent_companies = Company.query.filter(Company.created_at >= yesterday).count()
|
|
recent_time_entries = TimeEntry.query.filter(TimeEntry.arrival_time >= yesterday).count()
|
|
|
|
# System health
|
|
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()
|
|
unverified_users = User.query.filter_by(is_verified=False).count()
|
|
|
|
return jsonify({
|
|
'totals': {
|
|
'companies': total_companies,
|
|
'users': total_users,
|
|
'teams': total_teams,
|
|
'projects': total_projects,
|
|
'time_entries': total_time_entries
|
|
},
|
|
'active': {
|
|
'sessions': active_sessions,
|
|
'paused_sessions': paused_sessions
|
|
},
|
|
'recent': {
|
|
'users': recent_users,
|
|
'companies': recent_companies,
|
|
'time_entries': recent_time_entries
|
|
},
|
|
'health': {
|
|
'orphaned_users': orphaned_users,
|
|
'orphaned_time_entries': orphaned_time_entries,
|
|
'blocked_users': blocked_users,
|
|
'unverified_users': unverified_users
|
|
}
|
|
})
|
|
|
|
@app.route('/api/system-admin/companies/<int:company_id>/users')
|
|
@system_admin_required
|
|
def api_company_users(company_id):
|
|
"""API: Get users for a specific company (System Admin only)"""
|
|
company = Company.query.get_or_404(company_id)
|
|
users = User.query.filter_by(company_id=company.id).order_by(User.username).all()
|
|
|
|
return jsonify({
|
|
'company': {
|
|
'id': company.id,
|
|
'name': company.name,
|
|
'is_personal': company.is_personal
|
|
},
|
|
'users': [{
|
|
'id': user.id,
|
|
'username': user.username,
|
|
'email': user.email,
|
|
'role': user.role.value,
|
|
'is_blocked': user.is_blocked,
|
|
'is_verified': user.is_verified,
|
|
'created_at': user.created_at.isoformat(),
|
|
'team_id': user.team_id
|
|
} for user in users]
|
|
})
|
|
|
|
@app.route('/api/system-admin/users/<int:user_id>/toggle-block', methods=['POST'])
|
|
@system_admin_required
|
|
def api_toggle_user_block(user_id):
|
|
"""API: Toggle user blocked status (System Admin only)"""
|
|
user = User.query.get_or_404(user_id)
|
|
|
|
# Safety check: prevent blocking yourself
|
|
if user.id == g.user.id:
|
|
return jsonify({'error': 'Cannot block your own account'}), 400
|
|
|
|
# Safety check: prevent blocking the last system admin
|
|
if user.role == Role.SYSTEM_ADMIN and not user.is_blocked:
|
|
system_admin_count = User.query.filter_by(role=Role.SYSTEM_ADMIN, is_blocked=False).count()
|
|
if system_admin_count <= 1:
|
|
return jsonify({'error': 'Cannot block the last system administrator'}), 400
|
|
|
|
user.is_blocked = not user.is_blocked
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'id': user.id,
|
|
'username': user.username,
|
|
'is_blocked': user.is_blocked,
|
|
'message': f'User {"blocked" if user.is_blocked else "unblocked"} successfully'
|
|
})
|
|
|
|
@app.route('/api/system-admin/companies/<int:company_id>/stats')
|
|
@system_admin_required
|
|
def api_company_stats(company_id):
|
|
"""API: Get detailed statistics for a specific company"""
|
|
company = Company.query.get_or_404(company_id)
|
|
|
|
# User counts by role
|
|
role_counts = {}
|
|
for role in Role:
|
|
count = User.query.filter_by(company_id=company.id, role=role).count()
|
|
if count > 0:
|
|
role_counts[role.value] = count
|
|
|
|
# Team and project counts
|
|
team_count = Team.query.filter_by(company_id=company.id).count()
|
|
project_count = Project.query.filter_by(company_id=company.id).count()
|
|
active_projects = Project.query.filter_by(company_id=company.id, is_active=True).count()
|
|
|
|
# Time entries statistics
|
|
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/<int:project_id>/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/<int:task_id>', methods=['GET'])
|
|
@role_required(Role.TEAM_MEMBER)
|
|
@company_required
|
|
def get_task(task_id):
|
|
try:
|
|
task = Task.query.join(Project).filter(
|
|
Task.id == task_id,
|
|
Project.company_id == g.user.company_id
|
|
).first()
|
|
|
|
if not task or not task.can_user_access(g.user):
|
|
return jsonify({'success': False, 'message': 'Task not found or access denied'})
|
|
|
|
task_data = {
|
|
'id': task.id,
|
|
'task_number': getattr(task, 'task_number', f'TSK-{task.id:03d}'),
|
|
'name': task.name,
|
|
'description': task.description,
|
|
'status': task.status.name,
|
|
'priority': task.priority.name,
|
|
'estimated_hours': task.estimated_hours,
|
|
'assigned_to_id': task.assigned_to_id,
|
|
'assigned_to_name': task.assigned_to.username if task.assigned_to else None,
|
|
'project_id': task.project_id,
|
|
'project_name': task.project.name if task.project else None,
|
|
'project_code': task.project.code if task.project else None,
|
|
'start_date': task.start_date.isoformat() if task.start_date else None,
|
|
'due_date': task.due_date.isoformat() if task.due_date else None,
|
|
'completed_date': task.completed_date.isoformat() if task.completed_date else None,
|
|
'archived_date': task.archived_date.isoformat() if task.archived_date else None,
|
|
'sprint_id': task.sprint_id,
|
|
'subtasks': [{
|
|
'id': subtask.id,
|
|
'name': subtask.name,
|
|
'status': subtask.status.name,
|
|
'priority': subtask.priority.name,
|
|
'assigned_to_id': subtask.assigned_to_id,
|
|
'assigned_to_name': subtask.assigned_to.username if subtask.assigned_to else None
|
|
} for subtask in task.subtasks] if task.subtasks else []
|
|
}
|
|
|
|
return jsonify(task_data)
|
|
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'message': str(e)})
|
|
|
|
@app.route('/api/tasks/<int:task_id>', methods=['PUT'])
|
|
@role_required(Role.TEAM_MEMBER)
|
|
@company_required
|
|
def update_task(task_id):
|
|
try:
|
|
task = Task.query.join(Project).filter(
|
|
Task.id == task_id,
|
|
Project.company_id == g.user.company_id
|
|
).first()
|
|
|
|
if not task or not task.can_user_access(g.user):
|
|
return jsonify({'success': False, 'message': 'Task not found or access denied'})
|
|
|
|
data = request.get_json()
|
|
|
|
# Update task fields
|
|
if 'name' in data:
|
|
task.name = data['name']
|
|
if 'description' in data:
|
|
task.description = data['description']
|
|
if 'status' in data:
|
|
task.status = TaskStatus[data['status']]
|
|
if data['status'] == 'COMPLETED':
|
|
task.completed_date = datetime.now().date()
|
|
else:
|
|
task.completed_date = None
|
|
if 'priority' in data:
|
|
task.priority = TaskPriority[data['priority']]
|
|
if 'estimated_hours' in data:
|
|
task.estimated_hours = float(data['estimated_hours']) if data['estimated_hours'] else None
|
|
if 'assigned_to_id' in data:
|
|
task.assigned_to_id = int(data['assigned_to_id']) if data['assigned_to_id'] else None
|
|
if 'sprint_id' in data:
|
|
task.sprint_id = int(data['sprint_id']) if data['sprint_id'] else None
|
|
if 'start_date' in data:
|
|
task.start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date() if data['start_date'] else None
|
|
if 'due_date' in data:
|
|
task.due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date() if data['due_date'] else None
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'message': 'Task updated successfully'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return jsonify({'success': False, 'message': str(e)})
|
|
|
|
@app.route('/api/tasks/<int:task_id>', methods=['DELETE'])
|
|
@role_required(Role.TEAM_LEADER) # Only team leaders and above can delete tasks
|
|
@company_required
|
|
def delete_task(task_id):
|
|
try:
|
|
task = Task.query.join(Project).filter(
|
|
Task.id == task_id,
|
|
Project.company_id == g.user.company_id
|
|
).first()
|
|
|
|
if not task or not task.can_user_access(g.user):
|
|
return jsonify({'success': False, 'message': 'Task not found or access denied'})
|
|
|
|
db.session.delete(task)
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'message': 'Task deleted successfully'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return jsonify({'success': False, 'message': str(e)})
|
|
|
|
# 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/<int:task_id>/status', methods=['PUT'])
|
|
@role_required(Role.TEAM_MEMBER)
|
|
@company_required
|
|
def update_task_status(task_id):
|
|
"""Update task status"""
|
|
try:
|
|
task = Task.query.join(Project).filter(
|
|
Task.id == task_id,
|
|
Project.company_id == g.user.company_id
|
|
).first()
|
|
|
|
if not task or not task.can_user_access(g.user):
|
|
return jsonify({'success': False, 'message': 'Task not found or access denied'})
|
|
|
|
data = request.get_json()
|
|
new_status = data.get('status')
|
|
|
|
if not new_status:
|
|
return jsonify({'success': False, 'message': 'Status is required'})
|
|
|
|
# Validate status value - convert from enum name to enum object
|
|
try:
|
|
task_status = TaskStatus[new_status]
|
|
except KeyError:
|
|
return jsonify({'success': False, 'message': 'Invalid status value'})
|
|
|
|
# Update task status
|
|
old_status = task.status
|
|
task.status = task_status
|
|
|
|
# Set completion date if status is 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/<int:task_id>/dependencies')
|
|
@role_required(Role.TEAM_MEMBER)
|
|
@company_required
|
|
def get_task_dependencies(task_id):
|
|
"""Get dependencies for a specific task"""
|
|
try:
|
|
# Get the task and verify ownership
|
|
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/<int:task_id>/dependencies', methods=['POST'])
|
|
@role_required(Role.TEAM_MEMBER)
|
|
@company_required
|
|
def add_task_dependency(task_id):
|
|
"""Add a dependency for a task"""
|
|
try:
|
|
data = request.get_json()
|
|
task_number = data.get('task_number')
|
|
dependency_type = data.get('type') # 'blocked_by' or 'blocks'
|
|
|
|
if not task_number or not dependency_type:
|
|
return jsonify({'success': False, 'message': 'Task number and type are required'})
|
|
|
|
# Get the main task
|
|
task = Task.query.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/<int:task_id>/dependencies/<int:dependency_task_id>', methods=['DELETE'])
|
|
@role_required(Role.TEAM_MEMBER)
|
|
@company_required
|
|
def remove_task_dependency(task_id, dependency_task_id):
|
|
"""Remove a dependency for a task"""
|
|
try:
|
|
data = request.get_json()
|
|
dependency_type = data.get('type') # 'blocked_by' or 'blocks'
|
|
|
|
if not dependency_type:
|
|
return jsonify({'success': False, 'message': 'Dependency type is required'})
|
|
|
|
# Get the main task
|
|
task = Task.query.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/<int:task_id>/archive', methods=['POST'])
|
|
@role_required(Role.TEAM_MEMBER)
|
|
@company_required
|
|
def archive_task(task_id):
|
|
"""Archive a completed task"""
|
|
try:
|
|
# Get the task and verify ownership through project
|
|
task = Task.query.join(Project).filter(
|
|
Task.id == task_id,
|
|
Project.company_id == g.user.company_id
|
|
).first()
|
|
if not task:
|
|
return jsonify({'success': False, 'message': 'Task not found'})
|
|
|
|
# Only allow archiving completed tasks
|
|
if task.status != TaskStatus.COMPLETED:
|
|
return jsonify({'success': False, 'message': 'Only completed tasks can be archived'})
|
|
|
|
# Archive the task
|
|
task.status = TaskStatus.ARCHIVED
|
|
task.archived_date = datetime.now().date()
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Task archived successfully',
|
|
'archived_date': task.archived_date.isoformat()
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error archiving task: {str(e)}")
|
|
return jsonify({'success': False, 'message': str(e)})
|
|
|
|
|
|
@app.route('/api/tasks/<int:task_id>/restore', methods=['POST'])
|
|
@role_required(Role.TEAM_MEMBER)
|
|
@company_required
|
|
def restore_task(task_id):
|
|
"""Restore an archived task to completed status"""
|
|
try:
|
|
# Get the task and verify ownership through project
|
|
task = Task.query.join(Project).filter(
|
|
Task.id == task_id,
|
|
Project.company_id == g.user.company_id
|
|
).first()
|
|
if not task:
|
|
return jsonify({'success': False, 'message': 'Task not found'})
|
|
|
|
# Only allow restoring archived tasks
|
|
if task.status != TaskStatus.ARCHIVED:
|
|
return jsonify({'success': False, 'message': 'Only archived tasks can be restored'})
|
|
|
|
# Restore the task to completed status
|
|
task.status = TaskStatus.COMPLETED
|
|
task.archived_date = None
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Task restored successfully'
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error restoring task: {str(e)}")
|
|
return jsonify({'success': False, 'message': str(e)})
|
|
|
|
|
|
# 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/<int:sprint_id>', methods=['PUT'])
|
|
@role_required(Role.TEAM_LEADER)
|
|
@company_required
|
|
def update_sprint(sprint_id):
|
|
"""Update an existing sprint"""
|
|
try:
|
|
sprint = Sprint.query.filter_by(id=sprint_id, company_id=g.user.company_id).first()
|
|
|
|
if not sprint or not sprint.can_user_access(g.user):
|
|
return jsonify({'success': False, 'message': 'Sprint not found or access denied'})
|
|
|
|
data = request.get_json()
|
|
|
|
# Update sprint fields
|
|
if 'name' in data:
|
|
sprint.name = data['name']
|
|
if 'description' in data:
|
|
sprint.description = data['description']
|
|
if 'status' in data:
|
|
sprint.status = SprintStatus[data['status']]
|
|
if 'goal' in data:
|
|
sprint.goal = data['goal']
|
|
if 'capacity_hours' in data:
|
|
sprint.capacity_hours = int(data['capacity_hours']) if data['capacity_hours'] else None
|
|
if 'project_id' in data:
|
|
project_id = data['project_id']
|
|
if project_id:
|
|
project = Project.query.filter_by(id=project_id, company_id=g.user.company_id).first()
|
|
if not project or not project.is_user_allowed(g.user):
|
|
return jsonify({'success': False, 'message': 'Project not found or access denied'})
|
|
sprint.project_id = int(project_id)
|
|
else:
|
|
sprint.project_id = None
|
|
|
|
# Update dates if provided
|
|
if 'start_date' in data:
|
|
try:
|
|
sprint.start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date()
|
|
except ValueError:
|
|
return jsonify({'success': False, 'message': 'Invalid start date format'})
|
|
|
|
if 'end_date' in data:
|
|
try:
|
|
sprint.end_date = datetime.strptime(data['end_date'], '%Y-%m-%d').date()
|
|
except ValueError:
|
|
return jsonify({'success': False, 'message': 'Invalid end date format'})
|
|
|
|
# Validate date order
|
|
if sprint.start_date >= sprint.end_date:
|
|
return jsonify({'success': False, 'message': 'End date must be after start date'})
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'message': 'Sprint updated successfully'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error updating sprint: {str(e)}")
|
|
return jsonify({'success': False, 'message': str(e)})
|
|
|
|
@app.route('/api/sprints/<int:sprint_id>', methods=['DELETE'])
|
|
@role_required(Role.TEAM_LEADER)
|
|
@company_required
|
|
def delete_sprint(sprint_id):
|
|
"""Delete a sprint and remove it from all associated tasks"""
|
|
try:
|
|
sprint = Sprint.query.filter_by(id=sprint_id, company_id=g.user.company_id).first()
|
|
|
|
if not sprint or not sprint.can_user_access(g.user):
|
|
return jsonify({'success': False, 'message': 'Sprint not found or access denied'})
|
|
|
|
# Remove sprint assignment from all tasks
|
|
Task.query.filter_by(sprint_id=sprint_id).update({'sprint_id': None})
|
|
|
|
# Delete the sprint
|
|
db.session.delete(sprint)
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'message': 'Sprint deleted successfully'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error deleting sprint: {str(e)}")
|
|
return jsonify({'success': False, 'message': str(e)})
|
|
|
|
# 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/<int:subtask_id>', methods=['GET'])
|
|
@role_required(Role.TEAM_MEMBER)
|
|
@company_required
|
|
def get_subtask(subtask_id):
|
|
try:
|
|
subtask = SubTask.query.join(Task).join(Project).filter(
|
|
SubTask.id == subtask_id,
|
|
Project.company_id == g.user.company_id
|
|
).first()
|
|
|
|
if not subtask or not subtask.can_user_access(g.user):
|
|
return jsonify({'success': False, 'message': 'Subtask not found or access denied'})
|
|
|
|
subtask_data = {
|
|
'id': subtask.id,
|
|
'name': subtask.name,
|
|
'description': subtask.description,
|
|
'status': subtask.status.name,
|
|
'priority': subtask.priority.name,
|
|
'estimated_hours': subtask.estimated_hours,
|
|
'assigned_to_id': subtask.assigned_to_id,
|
|
'start_date': subtask.start_date.isoformat() if subtask.start_date else None,
|
|
'due_date': subtask.due_date.isoformat() if subtask.due_date else None
|
|
}
|
|
|
|
return jsonify({'success': True, 'subtask': subtask_data})
|
|
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'message': str(e)})
|
|
|
|
@app.route('/api/subtasks/<int:subtask_id>', methods=['PUT'])
|
|
@role_required(Role.TEAM_MEMBER)
|
|
@company_required
|
|
def update_subtask(subtask_id):
|
|
try:
|
|
subtask = SubTask.query.join(Task).join(Project).filter(
|
|
SubTask.id == subtask_id,
|
|
Project.company_id == g.user.company_id
|
|
).first()
|
|
|
|
if not subtask or not subtask.can_user_access(g.user):
|
|
return jsonify({'success': False, 'message': 'Subtask not found or access denied'})
|
|
|
|
data = request.get_json()
|
|
|
|
# Update subtask fields
|
|
if 'name' in data:
|
|
subtask.name = data['name']
|
|
if 'description' in data:
|
|
subtask.description = data['description']
|
|
if 'status' in data:
|
|
subtask.status = TaskStatus[data['status']]
|
|
if data['status'] == 'COMPLETED':
|
|
subtask.completed_date = datetime.now().date()
|
|
else:
|
|
subtask.completed_date = None
|
|
if 'priority' in data:
|
|
subtask.priority = TaskPriority[data['priority']]
|
|
if 'estimated_hours' in data:
|
|
subtask.estimated_hours = float(data['estimated_hours']) if data['estimated_hours'] else None
|
|
if 'assigned_to_id' in data:
|
|
subtask.assigned_to_id = int(data['assigned_to_id']) if data['assigned_to_id'] else None
|
|
if 'start_date' in data:
|
|
subtask.start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date() if data['start_date'] else None
|
|
if 'due_date' in data:
|
|
subtask.due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date() if data['due_date'] else None
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'message': 'Subtask updated successfully'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return jsonify({'success': False, 'message': str(e)})
|
|
|
|
@app.route('/api/subtasks/<int:subtask_id>', methods=['DELETE'])
|
|
@role_required(Role.TEAM_LEADER) # Only team leaders and above can delete subtasks
|
|
@company_required
|
|
def delete_subtask(subtask_id):
|
|
try:
|
|
subtask = SubTask.query.join(Task).join(Project).filter(
|
|
SubTask.id == subtask_id,
|
|
Project.company_id == g.user.company_id
|
|
).first()
|
|
|
|
if not subtask or not subtask.can_user_access(g.user):
|
|
return jsonify({'success': False, 'message': 'Subtask not found or access denied'})
|
|
|
|
db.session.delete(subtask)
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'message': 'Subtask deleted successfully'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return jsonify({'success': False, 'message': str(e)})
|
|
|
|
# Comment API Routes
|
|
@app.route('/api/tasks/<int:task_id>/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/<int:task_id>/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/<int:comment_id>', 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/<int:comment_id>', 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/<int:category_id>', methods=['PUT'])
|
|
@role_required(Role.ADMIN)
|
|
@company_required
|
|
def update_category(category_id):
|
|
try:
|
|
category = ProjectCategory.query.filter_by(
|
|
id=category_id,
|
|
company_id=g.user.company_id
|
|
).first()
|
|
|
|
if not category:
|
|
return jsonify({'success': False, 'message': 'Category not found'})
|
|
|
|
data = request.get_json()
|
|
name = data.get('name')
|
|
|
|
if not name:
|
|
return jsonify({'success': False, 'message': 'Category name is required'})
|
|
|
|
# Check if name conflicts with another category
|
|
existing = ProjectCategory.query.filter(
|
|
ProjectCategory.name == name,
|
|
ProjectCategory.company_id == g.user.company_id,
|
|
ProjectCategory.id != category_id
|
|
).first()
|
|
|
|
if existing:
|
|
return jsonify({'success': False, 'message': 'Category name already exists'})
|
|
|
|
category.name = name
|
|
category.description = data.get('description', '')
|
|
category.color = data.get('color', category.color)
|
|
category.icon = data.get('icon', '')
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'message': 'Category updated successfully'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return jsonify({'success': False, 'message': str(e)})
|
|
|
|
@app.route('/api/admin/categories/<int:category_id>', methods=['DELETE'])
|
|
@role_required(Role.ADMIN)
|
|
@company_required
|
|
def delete_category(category_id):
|
|
try:
|
|
category = ProjectCategory.query.filter_by(
|
|
id=category_id,
|
|
company_id=g.user.company_id
|
|
).first()
|
|
|
|
if not category:
|
|
return jsonify({'success': False, 'message': 'Category not found'})
|
|
|
|
# Unassign projects from this category
|
|
projects = Project.query.filter_by(category_id=category_id).all()
|
|
for project in projects:
|
|
project.category_id = None
|
|
|
|
db.session.delete(category)
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'message': 'Category deleted successfully'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
return jsonify({'success': False, 'message': str(e)})
|
|
|
|
# Dashboard API Endpoints
|
|
@app.route('/api/dashboard')
|
|
@role_required(Role.TEAM_MEMBER)
|
|
@company_required
|
|
def get_dashboard():
|
|
"""Get user's dashboard configuration and widgets."""
|
|
try:
|
|
# Get or create user dashboard
|
|
dashboard = UserDashboard.query.filter_by(user_id=g.user.id).first()
|
|
if not dashboard:
|
|
dashboard = UserDashboard(user_id=g.user.id)
|
|
db.session.add(dashboard)
|
|
db.session.commit()
|
|
logger.info(f"Created new dashboard {dashboard.id} for user {g.user.id}")
|
|
else:
|
|
logger.info(f"Using existing dashboard {dashboard.id} for user {g.user.id}")
|
|
|
|
# Get user's widgets
|
|
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}")
|
|
|
|
# Convert to JSON format
|
|
widget_data = []
|
|
for widget in widgets:
|
|
# Convert grid size to simple size names
|
|
if widget.grid_width == 1 and widget.grid_height == 1:
|
|
size = 'small'
|
|
elif widget.grid_width == 2 and widget.grid_height == 1:
|
|
size = 'medium'
|
|
elif widget.grid_width == 2 and widget.grid_height == 2:
|
|
size = 'large'
|
|
elif widget.grid_width == 3 and widget.grid_height == 1:
|
|
size = 'wide'
|
|
else:
|
|
size = 'small'
|
|
|
|
# Parse config JSON
|
|
try:
|
|
import json
|
|
config = json.loads(widget.config) if widget.config else {}
|
|
except (json.JSONDecodeError, TypeError):
|
|
config = {}
|
|
|
|
widget_data.append({
|
|
'id': widget.id,
|
|
'type': widget.widget_type.value,
|
|
'title': widget.title,
|
|
'size': size,
|
|
'grid_x': widget.grid_x,
|
|
'grid_y': widget.grid_y,
|
|
'grid_width': widget.grid_width,
|
|
'grid_height': widget.grid_height,
|
|
'config': config
|
|
})
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'dashboard': {
|
|
'id': dashboard.id,
|
|
'layout_config': dashboard.layout_config,
|
|
'grid_columns': dashboard.grid_columns,
|
|
'theme': dashboard.theme
|
|
},
|
|
'widgets': widget_data
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error loading dashboard: {e}")
|
|
return jsonify({'success': False, 'error': str(e)})
|
|
|
|
@app.route('/api/dashboard/widgets', methods=['POST'])
|
|
@role_required(Role.TEAM_MEMBER)
|
|
@company_required
|
|
def create_or_update_widget():
|
|
"""Create or update a dashboard widget."""
|
|
try:
|
|
data = request.get_json()
|
|
|
|
# Get or create user dashboard
|
|
dashboard = UserDashboard.query.filter_by(user_id=g.user.id).first()
|
|
if not dashboard:
|
|
dashboard = UserDashboard(user_id=g.user.id)
|
|
db.session.add(dashboard)
|
|
db.session.flush() # Get the ID
|
|
logger.info(f"Created new dashboard {dashboard.id} for user {g.user.id} in widget creation")
|
|
else:
|
|
logger.info(f"Using existing dashboard {dashboard.id} for user {g.user.id} in widget creation")
|
|
|
|
# Check if updating existing widget
|
|
widget_id = data.get('widget_id')
|
|
if widget_id:
|
|
widget = DashboardWidget.query.filter_by(
|
|
id=widget_id,
|
|
dashboard_id=dashboard.id
|
|
).first()
|
|
if not widget:
|
|
return jsonify({'success': False, 'error': 'Widget not found'})
|
|
else:
|
|
# Create new widget
|
|
widget = DashboardWidget(dashboard_id=dashboard.id)
|
|
# Find next available position
|
|
max_y = db.session.query(func.max(DashboardWidget.grid_y)).filter_by(
|
|
dashboard_id=dashboard.id
|
|
).scalar() or 0
|
|
widget.grid_y = max_y + 1
|
|
widget.grid_x = 0
|
|
|
|
# Update widget properties
|
|
widget.widget_type = WidgetType(data['type'])
|
|
widget.title = data['title']
|
|
|
|
# Convert size to grid dimensions
|
|
size = data.get('size', 'small')
|
|
if size == 'small':
|
|
widget.grid_width = 1
|
|
widget.grid_height = 1
|
|
elif size == 'medium':
|
|
widget.grid_width = 2
|
|
widget.grid_height = 1
|
|
elif size == 'large':
|
|
widget.grid_width = 2
|
|
widget.grid_height = 2
|
|
elif size == 'wide':
|
|
widget.grid_width = 3
|
|
widget.grid_height = 1
|
|
|
|
# Build config from form data
|
|
config = {}
|
|
for key, value in data.items():
|
|
if key not in ['type', 'title', 'size', 'widget_id']:
|
|
config[key] = value
|
|
|
|
# Store config as JSON string
|
|
if config:
|
|
import json
|
|
widget.config = json.dumps(config)
|
|
else:
|
|
widget.config = None
|
|
|
|
if not widget_id:
|
|
db.session.add(widget)
|
|
logger.info(f"Creating new widget: {widget.widget_type.value} for dashboard {dashboard.id}")
|
|
else:
|
|
logger.info(f"Updating existing widget {widget_id}")
|
|
|
|
db.session.commit()
|
|
logger.info(f"Widget saved successfully with ID: {widget.id}")
|
|
|
|
# Verify the widget was actually saved
|
|
saved_widget = DashboardWidget.query.filter_by(id=widget.id).first()
|
|
if saved_widget:
|
|
logger.info(f"Verification: Widget {widget.id} exists in database with dashboard_id: {saved_widget.dashboard_id}")
|
|
else:
|
|
logger.error(f"Verification failed: Widget {widget.id} not found in database")
|
|
|
|
# Convert grid size back to simple size name for response
|
|
if widget.grid_width == 1 and widget.grid_height == 1:
|
|
size_name = 'small'
|
|
elif widget.grid_width == 2 and widget.grid_height == 1:
|
|
size_name = 'medium'
|
|
elif widget.grid_width == 2 and widget.grid_height == 2:
|
|
size_name = 'large'
|
|
elif widget.grid_width == 3 and widget.grid_height == 1:
|
|
size_name = 'wide'
|
|
else:
|
|
size_name = 'small'
|
|
|
|
# Parse config for response
|
|
try:
|
|
import json
|
|
config_dict = json.loads(widget.config) if widget.config else {}
|
|
except (json.JSONDecodeError, TypeError):
|
|
config_dict = {}
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Widget saved successfully',
|
|
'widget': {
|
|
'id': widget.id,
|
|
'type': widget.widget_type.value,
|
|
'title': widget.title,
|
|
'size': size_name,
|
|
'grid_x': widget.grid_x,
|
|
'grid_y': widget.grid_y,
|
|
'grid_width': widget.grid_width,
|
|
'grid_height': widget.grid_height,
|
|
'config': config_dict
|
|
}
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error saving widget: {e}")
|
|
return jsonify({'success': False, 'error': str(e)})
|
|
|
|
@app.route('/api/dashboard/widgets/<int:widget_id>', methods=['DELETE'])
|
|
@role_required(Role.TEAM_MEMBER)
|
|
@company_required
|
|
def delete_widget(widget_id):
|
|
"""Delete a dashboard widget."""
|
|
try:
|
|
# Get user dashboard
|
|
dashboard = UserDashboard.query.filter_by(user_id=g.user.id).first()
|
|
if not dashboard:
|
|
return jsonify({'success': False, 'error': 'Dashboard not found'})
|
|
|
|
# Find and delete widget
|
|
widget = DashboardWidget.query.filter_by(
|
|
id=widget_id,
|
|
dashboard_id=dashboard.id
|
|
).first()
|
|
|
|
if not widget:
|
|
return jsonify({'success': False, 'error': 'Widget not found'})
|
|
|
|
# No need to update positions for grid-based layout
|
|
|
|
db.session.delete(widget)
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'message': 'Widget deleted successfully'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error deleting widget: {e}")
|
|
return jsonify({'success': False, 'error': str(e)})
|
|
|
|
@app.route('/api/dashboard/positions', methods=['POST'])
|
|
@role_required(Role.TEAM_MEMBER)
|
|
@company_required
|
|
def update_widget_positions():
|
|
"""Update widget grid positions after drag and drop."""
|
|
try:
|
|
data = request.get_json()
|
|
positions = data.get('positions', [])
|
|
|
|
# Get user dashboard
|
|
dashboard = UserDashboard.query.filter_by(user_id=g.user.id).first()
|
|
if not dashboard:
|
|
return jsonify({'success': False, 'error': 'Dashboard not found'})
|
|
|
|
# Update grid positions
|
|
for pos_data in positions:
|
|
widget = DashboardWidget.query.filter_by(
|
|
id=pos_data['id'],
|
|
dashboard_id=dashboard.id
|
|
).first()
|
|
if widget:
|
|
# For now, just assign sequential grid positions
|
|
# In a more advanced implementation, we'd calculate actual grid coordinates
|
|
widget.grid_x = pos_data.get('grid_x', 0)
|
|
widget.grid_y = pos_data.get('grid_y', pos_data.get('position', 0))
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({'success': True, 'message': 'Positions updated successfully'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error updating positions: {e}")
|
|
return jsonify({'success': False, 'error': str(e)})
|
|
|
|
# Widget data endpoints
|
|
@app.route('/api/dashboard/widgets/<int:widget_id>/data')
|
|
@role_required(Role.TEAM_MEMBER)
|
|
@company_required
|
|
def get_widget_data(widget_id):
|
|
"""Get data for a specific widget."""
|
|
try:
|
|
# Get user dashboard
|
|
dashboard = UserDashboard.query.filter_by(user_id=g.user.id).first()
|
|
if not dashboard:
|
|
return jsonify({'success': False, 'error': 'Dashboard not found'})
|
|
|
|
# Find widget
|
|
widget = DashboardWidget.query.filter_by(
|
|
id=widget_id,
|
|
dashboard_id=dashboard.id
|
|
).first()
|
|
|
|
if not widget:
|
|
return jsonify({'success': False, 'error': 'Widget not found'})
|
|
|
|
# Get widget-specific data based on type
|
|
widget_data = {}
|
|
|
|
if widget.widget_type == WidgetType.DAILY_SUMMARY:
|
|
from datetime import datetime, timedelta
|
|
|
|
config = widget.config or {}
|
|
period = config.get('summary_period', 'daily')
|
|
|
|
# Calculate time summaries
|
|
now = datetime.now()
|
|
|
|
# Today's summary
|
|
start_of_today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
today_entries = TimeEntry.query.filter(
|
|
TimeEntry.user_id == g.user.id,
|
|
TimeEntry.arrival_time >= start_of_today,
|
|
TimeEntry.departure_time.isnot(None)
|
|
).all()
|
|
today_seconds = sum(entry.duration or 0 for entry in today_entries)
|
|
|
|
# This week's summary
|
|
start_of_week = start_of_today - timedelta(days=start_of_today.weekday())
|
|
week_entries = TimeEntry.query.filter(
|
|
TimeEntry.user_id == g.user.id,
|
|
TimeEntry.arrival_time >= start_of_week,
|
|
TimeEntry.departure_time.isnot(None)
|
|
).all()
|
|
week_seconds = sum(entry.duration or 0 for entry in week_entries)
|
|
|
|
# This month's summary
|
|
start_of_month = start_of_today.replace(day=1)
|
|
month_entries = TimeEntry.query.filter(
|
|
TimeEntry.user_id == g.user.id,
|
|
TimeEntry.arrival_time >= start_of_month,
|
|
TimeEntry.departure_time.isnot(None)
|
|
).all()
|
|
month_seconds = sum(entry.duration or 0 for entry in month_entries)
|
|
|
|
widget_data.update({
|
|
'today': f"{today_seconds // 3600}h {(today_seconds % 3600) // 60}m",
|
|
'week': f"{week_seconds // 3600}h {(week_seconds % 3600) // 60}m",
|
|
'month': f"{month_seconds // 3600}h {(month_seconds % 3600) // 60}m",
|
|
'entries_today': len(today_entries),
|
|
'entries_week': len(week_entries),
|
|
'entries_month': len(month_entries)
|
|
})
|
|
|
|
elif widget.widget_type == WidgetType.ACTIVE_PROJECTS:
|
|
config = widget.config or {}
|
|
project_filter = config.get('project_filter', 'all')
|
|
max_projects = int(config.get('max_projects', 5))
|
|
|
|
# Get user's projects
|
|
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
|
|
projects = Project.query.filter_by(
|
|
company_id=g.user.company_id,
|
|
is_active=True
|
|
).limit(max_projects).all()
|
|
elif g.user.team_id:
|
|
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)
|
|
).limit(max_projects).all()
|
|
else:
|
|
projects = []
|
|
|
|
widget_data['projects'] = [{
|
|
'id': p.id,
|
|
'name': p.name,
|
|
'code': p.code,
|
|
'description': p.description
|
|
} for p in projects]
|
|
|
|
elif widget.widget_type == WidgetType.ASSIGNED_TASKS:
|
|
config = widget.config or {}
|
|
task_filter = config.get('task_filter', 'assigned')
|
|
task_status = config.get('task_status', 'active')
|
|
|
|
# Get user's tasks based on filter
|
|
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)
|
|
else:
|
|
# Get tasks from user's projects
|
|
if g.user.team_id:
|
|
project_ids = [p.id for p in Project.query.filter(
|
|
Project.company_id == g.user.company_id,
|
|
db.or_(Project.team_id == g.user.team_id, Project.team_id == None)
|
|
).all()]
|
|
tasks = Task.query.filter(Task.project_id.in_(project_ids))
|
|
else:
|
|
tasks = Task.query.join(Project).filter(Project.company_id == g.user.company_id)
|
|
|
|
# Filter by status if specified
|
|
if task_status != 'all':
|
|
if task_status == 'active':
|
|
tasks = tasks.filter(Task.status.in_([TaskStatus.PENDING, TaskStatus.IN_PROGRESS]))
|
|
elif task_status == 'pending':
|
|
tasks = tasks.filter_by(status=TaskStatus.PENDING)
|
|
elif task_status == 'completed':
|
|
tasks = tasks.filter_by(status=TaskStatus.COMPLETED)
|
|
|
|
tasks = tasks.limit(10).all()
|
|
|
|
widget_data['tasks'] = [{
|
|
'id': t.id,
|
|
'name': t.name,
|
|
'description': t.description,
|
|
'status': t.status.value if t.status else 'Pending',
|
|
'priority': t.priority.value if t.priority else 'Medium',
|
|
'project_name': t.project.name if t.project else 'No Project'
|
|
} for t in tasks]
|
|
|
|
elif widget.widget_type == WidgetType.WEEKLY_CHART:
|
|
from datetime import datetime, timedelta
|
|
|
|
# Get weekly data for chart
|
|
now = datetime.now()
|
|
start_of_week = now - timedelta(days=now.weekday())
|
|
|
|
weekly_data = []
|
|
for i in range(7):
|
|
day = start_of_week + timedelta(days=i)
|
|
day_start = day.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
day_end = day_start + timedelta(days=1)
|
|
|
|
day_entries = TimeEntry.query.filter(
|
|
TimeEntry.user_id == g.user.id,
|
|
TimeEntry.arrival_time >= day_start,
|
|
TimeEntry.arrival_time < day_end,
|
|
TimeEntry.departure_time.isnot(None)
|
|
).all()
|
|
|
|
total_seconds = sum(entry.duration or 0 for entry in day_entries)
|
|
weekly_data.append({
|
|
'day': day.strftime('%A'),
|
|
'date': day.strftime('%Y-%m-%d'),
|
|
'hours': round(total_seconds / 3600, 2),
|
|
'entries': len(day_entries)
|
|
})
|
|
|
|
widget_data['weekly_data'] = weekly_data
|
|
|
|
elif widget.widget_type == WidgetType.TASK_PRIORITY:
|
|
# Get tasks by priority
|
|
if g.user.team_id:
|
|
project_ids = [p.id for p in Project.query.filter(
|
|
Project.company_id == g.user.company_id,
|
|
db.or_(Project.team_id == g.user.team_id, Project.team_id == None)
|
|
).all()]
|
|
tasks = Task.query.filter(
|
|
Task.project_id.in_(project_ids),
|
|
Task.assigned_to_id == g.user.id
|
|
).order_by(Task.priority.desc(), Task.created_at.desc()).limit(10).all()
|
|
else:
|
|
tasks = Task.query.filter_by(assigned_to_id=g.user.id).order_by(
|
|
Task.priority.desc(), Task.created_at.desc()
|
|
).limit(10).all()
|
|
|
|
widget_data['priority_tasks'] = [{
|
|
'id': t.id,
|
|
'name': t.name,
|
|
'description': t.description,
|
|
'priority': t.priority.value if t.priority else 'Medium',
|
|
'status': t.status.value if t.status else 'Pending',
|
|
'project_name': t.project.name if t.project else 'No Project'
|
|
} for t in tasks]
|
|
|
|
elif widget.widget_type == WidgetType.PROJECT_PROGRESS:
|
|
# Get project progress data
|
|
if g.user.role in [Role.ADMIN, Role.SUPERVISOR]:
|
|
projects = Project.query.filter_by(
|
|
company_id=g.user.company_id,
|
|
is_active=True
|
|
).limit(5).all()
|
|
elif g.user.team_id:
|
|
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)
|
|
).limit(5).all()
|
|
else:
|
|
projects = []
|
|
|
|
project_progress = []
|
|
for project in projects:
|
|
total_tasks = Task.query.filter_by(project_id=project.id).count()
|
|
completed_tasks = Task.query.filter_by(
|
|
project_id=project.id,
|
|
status=TaskStatus.COMPLETED
|
|
).count()
|
|
|
|
progress = (completed_tasks / total_tasks * 100) if total_tasks > 0 else 0
|
|
|
|
project_progress.append({
|
|
'id': project.id,
|
|
'name': project.name,
|
|
'code': project.code,
|
|
'progress': round(progress, 1),
|
|
'completed_tasks': completed_tasks,
|
|
'total_tasks': total_tasks
|
|
})
|
|
|
|
widget_data['project_progress'] = project_progress
|
|
|
|
elif widget.widget_type == WidgetType.PRODUCTIVITY_METRICS:
|
|
from datetime import datetime, timedelta
|
|
|
|
# Calculate productivity metrics
|
|
now = datetime.now()
|
|
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
week_ago = today - timedelta(days=7)
|
|
|
|
# This week vs last week comparison
|
|
this_week_entries = TimeEntry.query.filter(
|
|
TimeEntry.user_id == g.user.id,
|
|
TimeEntry.arrival_time >= week_ago,
|
|
TimeEntry.departure_time.isnot(None)
|
|
).all()
|
|
|
|
last_week_entries = TimeEntry.query.filter(
|
|
TimeEntry.user_id == g.user.id,
|
|
TimeEntry.arrival_time >= week_ago - timedelta(days=7),
|
|
TimeEntry.arrival_time < week_ago,
|
|
TimeEntry.departure_time.isnot(None)
|
|
).all()
|
|
|
|
this_week_hours = sum(entry.duration or 0 for entry in this_week_entries) / 3600
|
|
last_week_hours = sum(entry.duration or 0 for entry in last_week_entries) / 3600
|
|
|
|
productivity_change = ((this_week_hours - last_week_hours) / last_week_hours * 100) if last_week_hours > 0 else 0
|
|
|
|
widget_data.update({
|
|
'this_week_hours': round(this_week_hours, 1),
|
|
'last_week_hours': round(last_week_hours, 1),
|
|
'productivity_change': round(productivity_change, 1),
|
|
'avg_daily_hours': round(this_week_hours / 7, 1),
|
|
'total_entries': len(this_week_entries)
|
|
})
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'data': widget_data
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting widget data: {e}")
|
|
return jsonify({'success': False, 'error': str(e)})
|
|
|
|
@app.route('/api/current-timer-status')
|
|
@role_required(Role.TEAM_MEMBER)
|
|
@company_required
|
|
def get_current_timer_status():
|
|
"""Get current timer status for dashboard widgets."""
|
|
try:
|
|
# Get the user's current active time entry
|
|
active_entry = TimeEntry.query.filter_by(
|
|
user_id=g.user.id,
|
|
departure_time=None
|
|
).first()
|
|
|
|
if active_entry:
|
|
# Calculate current duration
|
|
now = datetime.now()
|
|
elapsed_seconds = int((now - active_entry.arrival_time).total_seconds())
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'isActive': True,
|
|
'startTime': active_entry.arrival_time.isoformat(),
|
|
'currentDuration': elapsed_seconds,
|
|
'entryId': active_entry.id
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'success': True,
|
|
'isActive': False,
|
|
'message': 'No active timer'
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting timer status: {e}")
|
|
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
|
|
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
|
|
if sprint.can_user_access(g.user)
|
|
]
|
|
|
|
sprint_list = [
|
|
{
|
|
'id': sprint.id,
|
|
'name': sprint.name,
|
|
'status': sprint.status.value
|
|
}
|
|
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)})
|
|
|
|
# Markdown rendering API
|
|
@app.route('/api/render-markdown', methods=['POST'])
|
|
@login_required
|
|
def render_markdown():
|
|
"""Render markdown content to HTML for preview"""
|
|
try:
|
|
data = request.get_json()
|
|
content = data.get('content', '')
|
|
|
|
if not content:
|
|
return jsonify({'html': ''})
|
|
|
|
# Import markdown here to avoid issues if not installed
|
|
import markdown
|
|
html = markdown.markdown(content, extensions=['extra', 'codehilite', 'toc'])
|
|
|
|
return jsonify({'html': html})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error rendering markdown: {str(e)}")
|
|
return jsonify({'html': '<p>Error rendering markdown</p>'})
|
|
|
|
# Note link deletion endpoint
|
|
@app.route('/api/notes/<int:note_id>/link', methods=['DELETE'])
|
|
@login_required
|
|
@company_required
|
|
def unlink_notes(note_id):
|
|
"""Remove a link between two notes"""
|
|
try:
|
|
note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first()
|
|
|
|
if not note:
|
|
return jsonify({'success': False, 'message': 'Note not found'})
|
|
|
|
if not note.can_user_edit(g.user):
|
|
return jsonify({'success': False, 'message': 'Permission denied'})
|
|
|
|
data = request.get_json()
|
|
target_note_id = data.get('target_note_id')
|
|
|
|
if not target_note_id:
|
|
return jsonify({'success': False, 'message': 'Target note ID required'})
|
|
|
|
# Find and remove the link
|
|
link = NoteLink.query.filter_by(
|
|
source_note_id=note_id,
|
|
target_note_id=target_note_id
|
|
).first()
|
|
|
|
if not link:
|
|
# Try reverse direction
|
|
link = NoteLink.query.filter_by(
|
|
source_note_id=target_note_id,
|
|
target_note_id=note_id
|
|
).first()
|
|
|
|
if link:
|
|
db.session.delete(link)
|
|
db.session.commit()
|
|
return jsonify({'success': True, 'message': 'Link removed successfully'})
|
|
else:
|
|
return jsonify({'success': False, 'message': 'Link not found'})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error removing note link: {str(e)}")
|
|
return jsonify({'success': False, 'message': str(e)})
|
|
|
|
if __name__ == '__main__':
|
|
port = int(os.environ.get('PORT', 5000))
|
|
app.run(debug=True, host='0.0.0.0', port=port) |