7123 lines
270 KiB
Python
7123 lines
270 KiB
Python
# Standard library imports
|
|
import base64
|
|
import csv
|
|
import io
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import tempfile
|
|
import time as time_module
|
|
import uuid
|
|
import zipfile
|
|
from datetime import datetime, time, timedelta
|
|
from functools import wraps
|
|
from urllib.parse import unquote
|
|
|
|
# Third-party imports
|
|
import markdown
|
|
import pandas as pd
|
|
import qrcode
|
|
from dotenv import load_dotenv
|
|
from flask import (Flask, Response, abort, flash, g, jsonify, redirect,
|
|
render_template, request, send_file, session, url_for)
|
|
from flask_mail import Mail, Message
|
|
from sqlalchemy import and_, func, or_
|
|
from werkzeug.security import check_password_hash
|
|
from werkzeug.utils import secure_filename
|
|
|
|
# Local application imports
|
|
from data_export import (export_analytics_csv, export_analytics_excel,
|
|
export_team_hours_to_csv, export_team_hours_to_excel,
|
|
export_to_csv, export_to_excel)
|
|
from data_formatting import (format_burndown_data, format_duration,
|
|
format_graph_data, format_table_data,
|
|
format_team_data, prepare_export_data,
|
|
prepare_team_hours_export_data)
|
|
from frontmatter_utils import parse_frontmatter
|
|
from migrate_db import (get_db_path, migrate_data as migrate_data_from_db,
|
|
migrate_postgresql_schema, migrate_task_system,
|
|
migrate_to_company_model, migrate_work_config_data,
|
|
run_all_migrations)
|
|
from models import (AccountType, Announcement, BrandingSettings, Comment,
|
|
CommentVisibility, Company, CompanySettings,
|
|
CompanyWorkConfig, DashboardWidget, Note, NoteFolder,
|
|
NoteLink, NoteVisibility, Project, ProjectCategory, Role,
|
|
Sprint, SprintStatus, SubTask, SystemEvent, SystemSettings,
|
|
Task, TaskDependency, TaskPriority, TaskStatus, Team,
|
|
TimeEntry, User, UserDashboard, UserPreferences,
|
|
WidgetTemplate, WidgetType, WorkConfig, WorkRegion, db)
|
|
from password_utils import PasswordValidator
|
|
from time_utils import (apply_time_rounding, format_date_by_preference,
|
|
format_datetime_by_preference, format_duration_readable,
|
|
format_time_by_preference, format_time_short_by_preference,
|
|
get_available_date_formats, get_available_rounding_options,
|
|
get_user_format_settings, get_user_rounding_settings,
|
|
round_duration_to_interval)
|
|
|
|
# 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:
|
|
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:
|
|
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_wrapper():
|
|
"""Migrate existing data to support company model (stub - handled by migrate_db)"""
|
|
try:
|
|
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:
|
|
migrate_data_from_db()
|
|
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_wrapper():
|
|
"""Migrate existing WorkConfig data to new architecture (stub - handled by migrate_db)"""
|
|
try:
|
|
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_wrapper():
|
|
"""Create tables for the task management system (stub - handled by migrate_db)"""
|
|
try:
|
|
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:
|
|
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 ''
|
|
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 ''
|
|
_, 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 ''
|
|
_, 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 ''
|
|
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'
|
|
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
|
|
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
|
|
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
|
|
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"""
|
|
|
|
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
|
|
|
|
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
|
|
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
|
|
)
|
|
|
|
# Sync metadata from frontmatter if present
|
|
note.sync_from_frontmatter()
|
|
|
|
# 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) and not link.target_note.is_archived:
|
|
outgoing_links.append(link)
|
|
|
|
for link in note.incoming_links:
|
|
if link.source_note.can_user_view(g.user) and not link.source_note.is_archived:
|
|
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>/mindmap')
|
|
@login_required
|
|
@company_required
|
|
def view_note_mindmap(slug):
|
|
"""View a note as a mind map"""
|
|
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)
|
|
|
|
return render_template('note_mindmap.html',
|
|
title=f"{note.title} - Mind Map",
|
|
note=note)
|
|
|
|
|
|
@app.route('/notes/<slug>/download/<format>')
|
|
@login_required
|
|
@company_required
|
|
def download_note(slug, format):
|
|
"""Download a note in various formats"""
|
|
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)
|
|
|
|
# Prepare filename
|
|
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title)
|
|
timestamp = datetime.now().strftime('%Y%m%d')
|
|
|
|
if format == 'md':
|
|
# Download as Markdown with frontmatter
|
|
content = note.content
|
|
response = Response(content, mimetype='text/markdown')
|
|
response.headers['Content-Disposition'] = f'attachment; filename="{safe_filename}_{timestamp}.md"'
|
|
return response
|
|
|
|
elif format == 'html':
|
|
# Download as HTML
|
|
html_content = f"""<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>{note.title}</title>
|
|
<style>
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 2rem; }}
|
|
h1, h2, h3 {{ margin-top: 2rem; }}
|
|
code {{ background: #f4f4f4; padding: 0.2rem 0.4rem; border-radius: 3px; }}
|
|
pre {{ background: #f4f4f4; padding: 1rem; border-radius: 5px; overflow-x: auto; }}
|
|
blockquote {{ border-left: 4px solid #ddd; margin: 0; padding-left: 1rem; color: #666; }}
|
|
.metadata {{ background: #f9f9f9; padding: 1rem; border-radius: 5px; margin-bottom: 2rem; }}
|
|
.metadata dl {{ margin: 0; }}
|
|
.metadata dt {{ font-weight: bold; display: inline-block; width: 120px; }}
|
|
.metadata dd {{ display: inline; margin: 0; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="metadata">
|
|
<h1>{note.title}</h1>
|
|
<dl>
|
|
<dt>Author:</dt><dd>{note.created_by.username}</dd><br>
|
|
<dt>Created:</dt><dd>{note.created_at.strftime('%Y-%m-%d %H:%M')}</dd><br>
|
|
<dt>Updated:</dt><dd>{note.updated_at.strftime('%Y-%m-%d %H:%M')}</dd><br>
|
|
<dt>Visibility:</dt><dd>{note.visibility.value}</dd><br>
|
|
{'<dt>Folder:</dt><dd>' + note.folder + '</dd><br>' if note.folder else ''}
|
|
{'<dt>Tags:</dt><dd>' + note.tags + '</dd><br>' if note.tags else ''}
|
|
</dl>
|
|
</div>
|
|
{note.render_html()}
|
|
</body>
|
|
</html>"""
|
|
response = Response(html_content, mimetype='text/html')
|
|
response.headers['Content-Disposition'] = f'attachment; filename="{safe_filename}_{timestamp}.html"'
|
|
return response
|
|
|
|
elif format == 'txt':
|
|
# Download as plain text
|
|
metadata, body = parse_frontmatter(note.content)
|
|
|
|
# Create plain text version
|
|
text_content = f"{note.title}\n{'=' * len(note.title)}\n\n"
|
|
text_content += f"Author: {note.created_by.username}\n"
|
|
text_content += f"Created: {note.created_at.strftime('%Y-%m-%d %H:%M')}\n"
|
|
text_content += f"Updated: {note.updated_at.strftime('%Y-%m-%d %H:%M')}\n"
|
|
text_content += f"Visibility: {note.visibility.value}\n"
|
|
if note.folder:
|
|
text_content += f"Folder: {note.folder}\n"
|
|
if note.tags:
|
|
text_content += f"Tags: {note.tags}\n"
|
|
text_content += "\n" + "-" * 40 + "\n\n"
|
|
|
|
# Remove markdown formatting
|
|
text_body = body
|
|
# Remove headers markdown
|
|
text_body = re.sub(r'^#+\s+', '', text_body, flags=re.MULTILINE)
|
|
# Remove emphasis
|
|
text_body = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text_body)
|
|
text_body = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text_body)
|
|
# Remove links but keep text
|
|
text_body = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text_body)
|
|
# Remove images
|
|
text_body = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'[Image: \1]', text_body)
|
|
# Remove code blocks markers
|
|
text_body = re.sub(r'```[^`]*```', lambda m: m.group(0).replace('```', ''), text_body, flags=re.DOTALL)
|
|
text_body = re.sub(r'`([^`]+)`', r'\1', text_body)
|
|
|
|
text_content += text_body
|
|
|
|
response = Response(text_content, mimetype='text/plain')
|
|
response.headers['Content-Disposition'] = f'attachment; filename="{safe_filename}_{timestamp}.txt"'
|
|
return response
|
|
|
|
else:
|
|
abort(404)
|
|
|
|
|
|
@app.route('/notes/download-bulk', methods=['POST'])
|
|
@login_required
|
|
@company_required
|
|
def download_notes_bulk():
|
|
"""Download multiple notes as a zip file"""
|
|
|
|
note_ids = request.form.getlist('note_ids[]')
|
|
format = request.form.get('format', 'md')
|
|
|
|
if not note_ids:
|
|
flash('No notes selected for download', 'error')
|
|
return redirect(url_for('notes_list'))
|
|
|
|
# Create a temporary file for the zip
|
|
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.zip')
|
|
|
|
try:
|
|
with zipfile.ZipFile(temp_file.name, 'w') as zipf:
|
|
for note_id in note_ids:
|
|
note = Note.query.filter_by(id=int(note_id), company_id=g.user.company_id).first()
|
|
if note and note.can_user_view(g.user):
|
|
# Get content based on format
|
|
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title)
|
|
|
|
if format == 'md':
|
|
content = note.content
|
|
filename = f"{safe_filename}.md"
|
|
elif format == 'html':
|
|
content = f"""<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>{note.title}</title>
|
|
<style>
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 2rem; }}
|
|
h1, h2, h3 {{ margin-top: 2rem; }}
|
|
code {{ background: #f4f4f4; padding: 0.2rem 0.4rem; border-radius: 3px; }}
|
|
pre {{ background: #f4f4f4; padding: 1rem; border-radius: 5px; overflow-x: auto; }}
|
|
blockquote {{ border-left: 4px solid #ddd; margin: 0; padding-left: 1rem; color: #666; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>{note.title}</h1>
|
|
{note.render_html()}
|
|
</body>
|
|
</html>"""
|
|
filename = f"{safe_filename}.html"
|
|
else: # txt
|
|
metadata, body = parse_frontmatter(note.content)
|
|
content = f"{note.title}\n{'=' * len(note.title)}\n\n{body}"
|
|
filename = f"{safe_filename}.txt"
|
|
|
|
# Add file to zip
|
|
zipf.writestr(filename, content)
|
|
|
|
# Send the zip file
|
|
temp_file.seek(0)
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
|
|
return send_file(
|
|
temp_file.name,
|
|
mimetype='application/zip',
|
|
as_attachment=True,
|
|
download_name=f'notes_{timestamp}.zip'
|
|
)
|
|
|
|
finally:
|
|
# Clean up temp file after sending
|
|
os.unlink(temp_file.name)
|
|
|
|
|
|
@app.route('/notes/folder/<path:folder_path>/download/<format>')
|
|
@login_required
|
|
@company_required
|
|
def download_folder(folder_path, format):
|
|
"""Download all notes in a folder as a zip file"""
|
|
|
|
# Decode folder path (replace URL encoding)
|
|
folder_path = unquote(folder_path)
|
|
|
|
# Get all notes in this folder
|
|
notes = Note.query.filter_by(
|
|
company_id=g.user.company_id,
|
|
folder=folder_path,
|
|
is_archived=False
|
|
).all()
|
|
|
|
# Filter notes user can view
|
|
viewable_notes = [note for note in notes if note.can_user_view(g.user)]
|
|
|
|
if not viewable_notes:
|
|
flash('No notes found in this folder or you don\'t have permission to view them.', 'warning')
|
|
return redirect(url_for('notes_list'))
|
|
|
|
# Create a temporary file for the zip
|
|
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.zip')
|
|
|
|
try:
|
|
with zipfile.ZipFile(temp_file.name, 'w') as zipf:
|
|
for note in viewable_notes:
|
|
# Get content based on format
|
|
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title)
|
|
|
|
if format == 'md':
|
|
content = note.content
|
|
filename = f"{safe_filename}.md"
|
|
elif format == 'html':
|
|
content = f"""<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>{note.title}</title>
|
|
<style>
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 2rem; }}
|
|
h1, h2, h3 {{ margin-top: 2rem; }}
|
|
code {{ background: #f4f4f4; padding: 0.2rem 0.4rem; border-radius: 3px; }}
|
|
pre {{ background: #f4f4f4; padding: 1rem; border-radius: 5px; overflow-x: auto; }}
|
|
blockquote {{ border-left: 4px solid #ddd; margin: 0; padding-left: 1rem; color: #666; }}
|
|
.metadata {{ background: #f9f9f9; padding: 1rem; border-radius: 5px; margin-bottom: 2rem; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="metadata">
|
|
<h1>{note.title}</h1>
|
|
<p>Author: {note.created_by.username} | Created: {note.created_at.strftime('%Y-%m-%d %H:%M')} | Folder: {note.folder}</p>
|
|
</div>
|
|
{note.render_html()}
|
|
</body>
|
|
</html>"""
|
|
filename = f"{safe_filename}.html"
|
|
else: # txt
|
|
metadata, body = parse_frontmatter(note.content)
|
|
# Remove markdown formatting
|
|
text_body = body
|
|
text_body = re.sub(r'^#+\s+', '', text_body, flags=re.MULTILINE)
|
|
text_body = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text_body)
|
|
text_body = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text_body)
|
|
text_body = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text_body)
|
|
text_body = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'[Image: \1]', text_body)
|
|
text_body = re.sub(r'```[^`]*```', lambda m: m.group(0).replace('```', ''), text_body, flags=re.DOTALL)
|
|
text_body = re.sub(r'`([^`]+)`', r'\1', text_body)
|
|
|
|
content = f"{note.title}\n{'=' * len(note.title)}\n\n"
|
|
content += f"Author: {note.created_by.username}\n"
|
|
content += f"Created: {note.created_at.strftime('%Y-%m-%d %H:%M')}\n"
|
|
content += f"Folder: {note.folder}\n\n"
|
|
content += "-" * 40 + "\n\n"
|
|
content += text_body
|
|
filename = f"{safe_filename}.txt"
|
|
|
|
# Add file to zip
|
|
zipf.writestr(filename, content)
|
|
|
|
# Send the zip file
|
|
temp_file.seek(0)
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
safe_folder_name = re.sub(r'[^a-zA-Z0-9_-]', '_', folder_path.replace('/', '_'))
|
|
|
|
return send_file(
|
|
temp_file.name,
|
|
mimetype='application/zip',
|
|
as_attachment=True,
|
|
download_name=f'{safe_folder_name}_notes_{timestamp}.zip'
|
|
)
|
|
|
|
finally:
|
|
# Clean up temp file after sending
|
|
os.unlink(temp_file.name)
|
|
|
|
|
|
@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
|
|
|
|
# Sync metadata from frontmatter if present
|
|
note.sync_from_frontmatter()
|
|
|
|
# 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)
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
target_roles = json.dumps(selected_roles)
|
|
|
|
if selected_companies:
|
|
target_companies = json.dumps([int(c) for c in selected_companies])
|
|
|
|
announcement = Announcement(
|
|
title=title,
|
|
content=content,
|
|
announcement_type=announcement_type,
|
|
is_urgent=is_urgent,
|
|
is_active=is_active,
|
|
start_date=start_datetime,
|
|
end_date=end_datetime,
|
|
target_all_users=target_all_users,
|
|
target_roles=target_roles,
|
|
target_companies=target_companies,
|
|
created_by_id=g.user.id
|
|
)
|
|
|
|
db.session.add(announcement)
|
|
db.session.commit()
|
|
|
|
flash('Announcement created successfully.', 'success')
|
|
return redirect(url_for('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:
|
|
announcement.target_roles = json.dumps(selected_roles)
|
|
else:
|
|
announcement.target_roles = None
|
|
|
|
if selected_companies:
|
|
announcement.target_companies = json.dumps([int(c) for c in selected_companies])
|
|
else:
|
|
announcement.target_companies = None
|
|
else:
|
|
announcement.target_roles = None
|
|
announcement.target_companies = None
|
|
|
|
announcement.updated_at = datetime.now()
|
|
|
|
db.session.commit()
|
|
|
|
flash('Announcement updated successfully.', 'success')
|
|
return redirect(url_for('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"""
|
|
|
|
# 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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
|
|
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:
|
|
|
|
# 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:
|
|
|
|
# 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
|
|
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) |