Add user avatars and comments for tasks.

This commit is contained in:
2025-07-06 16:44:06 +02:00
parent 19314bd532
commit 9bc3839587
10 changed files with 1831 additions and 137 deletions

View File

@@ -38,6 +38,9 @@ COPY . .
# Create the SQLite database directory with proper permissions # Create the SQLite database directory with proper permissions
RUN mkdir -p /app/instance && chmod 777 /app/instance RUN mkdir -p /app/instance && chmod 777 /app/instance
# Create uploads directory with proper permissions
RUN mkdir -p /app/static/uploads/avatars && chmod -R 777 /app/static/uploads
VOLUME /data VOLUME /data
RUN mkdir /data && chmod 777 /data RUN mkdir /data && chmod 777 /data

366
app.py
View File

@@ -1,5 +1,5 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, CompanySettings, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility
from data_formatting import ( from data_formatting import (
format_duration, prepare_export_data, prepare_team_hours_export_data, format_duration, prepare_export_data, prepare_team_hours_export_data,
format_table_data, format_graph_data, format_team_data, format_burndown_data format_table_data, format_graph_data, format_team_data, format_burndown_data
@@ -1322,6 +1322,134 @@ def profile():
return render_template('profile.html', title='My Profile', user=user) return render_template('profile.html', title='My Profile', user=user)
@app.route('/update-avatar', methods=['POST'])
@login_required
def update_avatar():
"""Update user avatar URL"""
user = User.query.get(session['user_id'])
avatar_url = request.form.get('avatar_url', '').strip()
# Validate URL if provided
if avatar_url:
# Basic URL validation
import re
url_pattern = re.compile(
r'^https?://' # http:// or https://
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' # domain...
r'localhost|' # localhost...
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
r'(?::\d+)?' # optional port
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
if not url_pattern.match(avatar_url):
flash('Please provide a valid URL for your avatar.', 'error')
return redirect(url_for('profile'))
# Additional validation for image URLs
allowed_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']
if not any(avatar_url.lower().endswith(ext) for ext in allowed_extensions):
# Check if it's a service that doesn't use extensions (like gravatar)
allowed_services = ['gravatar.com', 'dicebear.com', 'ui-avatars.com', 'avatars.githubusercontent.com']
if not any(service in avatar_url.lower() for service in allowed_services):
flash('Avatar URL should point to an image file (JPG, PNG, GIF, WebP, or SVG).', 'error')
return redirect(url_for('profile'))
# Update avatar URL (empty string removes custom avatar)
user.avatar_url = avatar_url if avatar_url else None
db.session.commit()
if avatar_url:
flash('Avatar updated successfully!', 'success')
else:
flash('Avatar reset to default.', 'success')
# Log the avatar change
SystemEvent.log_event(
event_type='profile_avatar_updated',
event_category='user',
description=f'User {user.username} updated their avatar',
user_id=user.id,
company_id=user.company_id
)
return redirect(url_for('profile'))
@app.route('/upload-avatar', methods=['POST'])
@login_required
def upload_avatar():
"""Handle avatar file upload"""
import os
from werkzeug.utils import secure_filename
import uuid
user = User.query.get(session['user_id'])
# Check if file was uploaded
if 'avatar_file' not in request.files:
flash('No file selected.', 'error')
return redirect(url_for('profile'))
file = request.files['avatar_file']
# Check if file is empty
if file.filename == '':
flash('No file selected.', 'error')
return redirect(url_for('profile'))
# Validate file extension
allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
file_ext = file.filename.rsplit('.', 1)[1].lower() if '.' in file.filename else ''
if file_ext not in allowed_extensions:
flash('Invalid file type. Please upload a PNG, JPG, GIF, or WebP image.', 'error')
return redirect(url_for('profile'))
# Validate file size (5MB max)
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0) # Reset file pointer
if file_size > 5 * 1024 * 1024: # 5MB
flash('File size must be less than 5MB.', 'error')
return redirect(url_for('profile'))
# Generate unique filename
unique_filename = f"{user.id}_{uuid.uuid4().hex}.{file_ext}"
# Create user avatar directory if it doesn't exist
avatar_dir = os.path.join(app.static_folder, 'uploads', 'avatars')
os.makedirs(avatar_dir, exist_ok=True)
# Save the file
file_path = os.path.join(avatar_dir, unique_filename)
file.save(file_path)
# Delete old avatar file if it exists and is a local upload
if user.avatar_url and user.avatar_url.startswith('/static/uploads/avatars/'):
old_file_path = os.path.join(app.root_path, user.avatar_url.lstrip('/'))
if os.path.exists(old_file_path):
try:
os.remove(old_file_path)
except Exception as e:
logger.warning(f"Failed to delete old avatar: {e}")
# Update user's avatar URL
user.avatar_url = f"/static/uploads/avatars/{unique_filename}"
db.session.commit()
flash('Avatar uploaded successfully!', 'success')
# Log the avatar upload
SystemEvent.log_event(
event_type='profile_avatar_uploaded',
event_category='user',
description=f'User {user.username} uploaded a new avatar',
user_id=user.id,
company_id=user.company_id
)
return redirect(url_for('profile'))
@app.route('/2fa/setup', methods=['GET', 'POST']) @app.route('/2fa/setup', methods=['GET', 'POST'])
@login_required @login_required
def setup_2fa(): def setup_2fa():
@@ -3730,7 +3858,8 @@ def unified_task_management():
'id': member.id, 'id': member.id,
'username': member.username, 'username': member.username,
'email': member.email, 'email': member.email,
'role': member.role.value if member.role else 'Team Member' 'role': member.role.value if member.role else 'Team Member',
'avatar_url': member.get_avatar_url(32)
} for member in team_members] } for member in team_members]
return render_template('unified_task_management.html', return render_template('unified_task_management.html',
@@ -4705,6 +4834,239 @@ def delete_subtask(subtask_id):
db.session.rollback() db.session.rollback()
return jsonify({'success': False, 'message': str(e)}) 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 # Category Management API Routes
@app.route('/api/admin/categories', methods=['POST']) @app.route('/api/admin/categories', methods=['POST'])
@role_required(Role.ADMIN) @role_required(Role.ADMIN)

View File

@@ -14,9 +14,9 @@ from datetime import datetime
try: try:
from app import app, db from app import app, db
from models import (User, TimeEntry, WorkConfig, SystemSettings, Team, Role, Project, from models import (User, TimeEntry, WorkConfig, SystemSettings, Team, Role, Project,
Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType, Company, CompanyWorkConfig, CompanySettings, UserPreferences, WorkRegion, AccountType,
ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent,
WidgetType, UserDashboard, DashboardWidget, WidgetTemplate) WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility)
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
FLASK_AVAILABLE = True FLASK_AVAILABLE = True
except ImportError: except ImportError:
@@ -74,6 +74,7 @@ def run_all_migrations(db_path=None):
migrate_task_system(db_path) migrate_task_system(db_path)
migrate_system_events(db_path) migrate_system_events(db_path)
migrate_dashboard_system(db_path) migrate_dashboard_system(db_path)
migrate_comment_system(db_path)
# Run PostgreSQL-specific migrations if applicable # Run PostgreSQL-specific migrations if applicable
if FLASK_AVAILABLE: if FLASK_AVAILABLE:
@@ -174,7 +175,8 @@ def run_basic_migrations(db_path):
('business_name', "ALTER TABLE user ADD COLUMN business_name VARCHAR(100)"), ('business_name', "ALTER TABLE user ADD COLUMN business_name VARCHAR(100)"),
('company_id', "ALTER TABLE user ADD COLUMN company_id INTEGER"), ('company_id', "ALTER TABLE user ADD COLUMN company_id INTEGER"),
('two_factor_enabled', "ALTER TABLE user ADD COLUMN two_factor_enabled BOOLEAN DEFAULT 0"), ('two_factor_enabled', "ALTER TABLE user ADD COLUMN two_factor_enabled BOOLEAN DEFAULT 0"),
('two_factor_secret', "ALTER TABLE user ADD COLUMN two_factor_secret VARCHAR(32)") ('two_factor_secret', "ALTER TABLE user ADD COLUMN two_factor_secret VARCHAR(32)"),
('avatar_url', "ALTER TABLE user ADD COLUMN avatar_url VARCHAR(255)")
] ]
for column_name, sql_command in user_migrations: for column_name, sql_command in user_migrations:
@@ -303,6 +305,28 @@ def create_missing_tables(cursor):
) )
""") """)
# Company Settings table
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='company_settings'")
if not cursor.fetchone():
print("Creating company_settings table...")
cursor.execute("""
CREATE TABLE company_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL,
default_comment_visibility VARCHAR(20) DEFAULT 'Company',
allow_team_visibility_comments BOOLEAN DEFAULT 1,
require_task_assignment BOOLEAN DEFAULT 0,
allow_task_creation_by_members BOOLEAN DEFAULT 1,
restrict_project_access_by_team BOOLEAN DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER,
FOREIGN KEY (company_id) REFERENCES company (id),
FOREIGN KEY (created_by_id) REFERENCES user (id),
UNIQUE(company_id)
)
""")
def migrate_to_company_model(db_path): def migrate_to_company_model(db_path):
"""Migrate to company-based multi-tenancy model.""" """Migrate to company-based multi-tenancy model."""
@@ -460,6 +484,7 @@ def migrate_user_roles(cursor):
business_name VARCHAR(100), business_name VARCHAR(100),
two_factor_enabled BOOLEAN DEFAULT 0, two_factor_enabled BOOLEAN DEFAULT 0,
two_factor_secret VARCHAR(32), two_factor_secret VARCHAR(32),
avatar_url VARCHAR(255),
FOREIGN KEY (company_id) REFERENCES company (id), FOREIGN KEY (company_id) REFERENCES company (id),
FOREIGN KEY (team_id) REFERENCES team (id) FOREIGN KEY (team_id) REFERENCES team (id)
) )
@@ -485,7 +510,7 @@ def migrate_user_roles(cursor):
WHEN account_type IN (?, ?) THEN account_type WHEN account_type IN (?, ?) THEN account_type
ELSE ? ELSE ?
END as account_type, END as account_type,
business_name, two_factor_enabled, two_factor_secret business_name, two_factor_enabled, two_factor_secret, avatar_url
FROM user FROM user
""", (default_company_id, Role.TEAM_MEMBER.value, Role.TEAM_LEADER.value, Role.SUPERVISOR.value, """, (default_company_id, Role.TEAM_MEMBER.value, Role.TEAM_LEADER.value, Role.SUPERVISOR.value,
Role.ADMIN.value, Role.SYSTEM_ADMIN.value, Role.TEAM_MEMBER.value, Role.ADMIN.value, Role.SYSTEM_ADMIN.value, Role.TEAM_MEMBER.value,
@@ -969,6 +994,7 @@ def create_all_tables(cursor):
business_name VARCHAR(100), business_name VARCHAR(100),
two_factor_enabled BOOLEAN DEFAULT 0, two_factor_enabled BOOLEAN DEFAULT 0,
two_factor_secret VARCHAR(32), two_factor_secret VARCHAR(32),
avatar_url VARCHAR(255),
FOREIGN KEY (company_id) REFERENCES company (id), FOREIGN KEY (company_id) REFERENCES company (id),
FOREIGN KEY (team_id) REFERENCES team (id) FOREIGN KEY (team_id) REFERENCES team (id)
) )
@@ -1118,6 +1144,89 @@ def migrate_postgresql_schema():
db.session.execute(text("CREATE INDEX idx_subtask_task_id ON sub_task(task_id)")) db.session.execute(text("CREATE INDEX idx_subtask_task_id ON sub_task(task_id)"))
db.session.commit() db.session.commit()
# Check if avatar_url column exists in user table
result = db.session.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'user' AND column_name = 'avatar_url'
"""))
if not result.fetchone():
print("Adding avatar_url column to user table...")
db.session.execute(text('ALTER TABLE "user" ADD COLUMN avatar_url VARCHAR(255)'))
db.session.commit()
# Check if comment table exists
result = db.session.execute(text("""
SELECT table_name
FROM information_schema.tables
WHERE table_name = 'comment'
"""))
if not result.fetchone():
print("Creating comment table...")
# Create comment visibility enum type if it doesn't exist
db.session.execute(text("""
DO $$ BEGIN
CREATE TYPE commentvisibility AS ENUM ('TEAM', 'COMPANY');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
"""))
db.session.execute(text("""
CREATE TABLE comment (
id SERIAL PRIMARY KEY,
content TEXT NOT NULL,
task_id INTEGER NOT NULL,
parent_comment_id INTEGER,
visibility commentvisibility DEFAULT 'COMPANY',
is_edited BOOLEAN DEFAULT FALSE,
edited_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL,
FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE,
FOREIGN KEY (parent_comment_id) REFERENCES comment (id),
FOREIGN KEY (created_by_id) REFERENCES "user" (id)
)
"""))
# Create indexes for better performance
db.session.execute(text("CREATE INDEX idx_comment_task ON comment(task_id)"))
db.session.execute(text("CREATE INDEX idx_comment_parent ON comment(parent_comment_id)"))
db.session.execute(text("CREATE INDEX idx_comment_created_by ON comment(created_by_id)"))
db.session.execute(text("CREATE INDEX idx_comment_created_at ON comment(created_at DESC)"))
db.session.commit()
# Check if company_settings table exists
result = db.session.execute(text("""
SELECT table_name
FROM information_schema.tables
WHERE table_name = 'company_settings'
"""))
if not result.fetchone():
print("Creating company_settings table...")
db.session.execute(text("""
CREATE TABLE company_settings (
id SERIAL PRIMARY KEY,
company_id INTEGER NOT NULL,
default_comment_visibility commentvisibility DEFAULT 'COMPANY',
allow_team_visibility_comments BOOLEAN DEFAULT TRUE,
require_task_assignment BOOLEAN DEFAULT FALSE,
allow_task_creation_by_members BOOLEAN DEFAULT TRUE,
restrict_project_access_by_team BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER,
FOREIGN KEY (company_id) REFERENCES company (id),
FOREIGN KEY (created_by_id) REFERENCES "user" (id),
UNIQUE(company_id)
)
"""))
db.session.commit()
print("PostgreSQL schema migration completed successfully!") print("PostgreSQL schema migration completed successfully!")
except Exception as e: except Exception as e:
@@ -1270,6 +1379,64 @@ def migrate_dashboard_system(db_file=None):
conn.close() conn.close()
def migrate_comment_system(db_file=None):
"""Migrate to add Comment system for tasks."""
db_path = get_db_path(db_file)
print(f"Migrating Comment system in {db_path}...")
if not os.path.exists(db_path):
print(f"Database file {db_path} does not exist. Run basic migration first.")
return False
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Check if comment table already exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='comment'")
if cursor.fetchone():
print("Comment table already exists. Skipping migration.")
return True
print("Creating Comment system table...")
# Create comment table
cursor.execute("""
CREATE TABLE comment (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
task_id INTEGER NOT NULL,
parent_comment_id INTEGER,
visibility VARCHAR(20) DEFAULT 'Company',
is_edited BOOLEAN DEFAULT 0,
edited_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL,
FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE,
FOREIGN KEY (parent_comment_id) REFERENCES comment (id),
FOREIGN KEY (created_by_id) REFERENCES user (id)
)
""")
# Create indexes for better performance
cursor.execute("CREATE INDEX idx_comment_task ON comment(task_id)")
cursor.execute("CREATE INDEX idx_comment_parent ON comment(parent_comment_id)")
cursor.execute("CREATE INDEX idx_comment_created_by ON comment(created_by_id)")
cursor.execute("CREATE INDEX idx_comment_created_at ON comment(created_at DESC)")
conn.commit()
print("Comment system migration completed successfully!")
return True
except Exception as e:
print(f"Error during Comment system migration: {e}")
conn.rollback()
raise
finally:
conn.close()
def main(): def main():
"""Main function with command line interface.""" """Main function with command line interface."""
parser = argparse.ArgumentParser(description='TimeTrack Database Migration Tool') parser = argparse.ArgumentParser(description='TimeTrack Database Migration Tool')

141
models.py
View File

@@ -161,6 +161,9 @@ class User(db.Model):
# Two-Factor Authentication fields # Two-Factor Authentication fields
two_factor_enabled = db.Column(db.Boolean, default=False) two_factor_enabled = db.Column(db.Boolean, default=False)
two_factor_secret = db.Column(db.String(32), nullable=True) # Base32 encoded secret two_factor_secret = db.Column(db.String(32), nullable=True) # Base32 encoded secret
# Avatar field
avatar_url = db.Column(db.String(255), nullable=True) # URL to user's avatar image
# Relationships # Relationships
time_entries = db.relationship('TimeEntry', backref='user', lazy=True) time_entries = db.relationship('TimeEntry', backref='user', lazy=True)
@@ -215,6 +218,42 @@ class User(db.Model):
totp = pyotp.TOTP(self.two_factor_secret) totp = pyotp.TOTP(self.two_factor_secret)
return totp.verify(token, valid_window=1) # Allow 1 window tolerance return totp.verify(token, valid_window=1) # Allow 1 window tolerance
def get_avatar_url(self, size=40):
"""Get user's avatar URL or generate a default one"""
if self.avatar_url:
return self.avatar_url
# Generate a default avatar using DiceBear Avatars (similar to GitHub's identicons)
# Using initials style for a clean, professional look
import hashlib
# Create a hash from username for consistent colors
hash_input = f"{self.username}_{self.id}".encode('utf-8')
hash_hex = hashlib.md5(hash_input).hexdigest()
# Use DiceBear API for avatar generation
# For initials style, we need to provide the actual initials
initials = self.get_initials()
# Generate avatar URL with initials
# Using a color based on the hash for consistency
bg_colors = ['0ea5e9', '8b5cf6', 'ec4899', 'f59e0b', '10b981', 'ef4444', '3b82f6', '6366f1']
color_index = int(hash_hex[:2], 16) % len(bg_colors)
bg_color = bg_colors[color_index]
avatar_url = f"https://api.dicebear.com/7.x/initials/svg?seed={initials}&size={size}&backgroundColor={bg_color}&fontSize=50"
return avatar_url
def get_initials(self):
"""Get user initials for avatar display"""
parts = self.username.split()
if len(parts) >= 2:
return f"{parts[0][0]}{parts[-1][0]}".upper()
elif self.username:
return self.username[:2].upper()
return "??"
def __repr__(self): def __repr__(self):
return f'<User {self.username}>' return f'<User {self.username}>'
@@ -362,6 +401,42 @@ class CompanyWorkConfig(db.Model):
} }
return presets.get(region, presets[WorkRegion.GERMANY]) return presets.get(region, presets[WorkRegion.GERMANY])
# Comment visibility enumeration
class CommentVisibility(enum.Enum):
TEAM = "Team" # Only visible to team members
COMPANY = "Company" # Visible to all company members
# Company Settings (General company preferences)
class CompanySettings(db.Model):
id = db.Column(db.Integer, primary_key=True)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Comment settings
default_comment_visibility = db.Column(db.Enum(CommentVisibility), default=CommentVisibility.COMPANY)
allow_team_visibility_comments = db.Column(db.Boolean, default=True) # Allow users to set comments as team-only
# Task settings
require_task_assignment = db.Column(db.Boolean, default=False) # Tasks must be assigned before work can begin
allow_task_creation_by_members = db.Column(db.Boolean, default=True) # Team members can create tasks
# Project settings
restrict_project_access_by_team = db.Column(db.Boolean, default=False) # Only team members can access team projects
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
# Relationships
company = db.relationship('Company', backref=db.backref('settings', uselist=False))
created_by = db.relationship('User', foreign_keys=[created_by_id])
# Unique constraint - one settings per company
__table_args__ = (db.UniqueConstraint('company_id', name='uq_company_settings'),)
def __repr__(self):
return f'<CompanySettings {self.company.name}>'
# User Preferences (User-configurable display settings) # User Preferences (User-configurable display settings)
class UserPreferences(db.Model): class UserPreferences(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@@ -597,6 +672,72 @@ class SubTask(db.Model):
"""Check if a user can access this subtask""" """Check if a user can access this subtask"""
return self.parent_task.can_user_access(user) return self.parent_task.can_user_access(user)
# Comment model for task discussions
class Comment(db.Model):
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.Text, nullable=False)
# Task association
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
# Parent comment for thread support
parent_comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'), nullable=True)
# Visibility setting
visibility = db.Column(db.Enum(CommentVisibility), default=CommentVisibility.COMPANY)
# Edit tracking
is_edited = db.Column(db.Boolean, default=False)
edited_at = db.Column(db.DateTime, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
task = db.relationship('Task', backref=db.backref('comments', lazy='dynamic', cascade='all, delete-orphan'))
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='comments')
replies = db.relationship('Comment', backref=db.backref('parent_comment', remote_side=[id]))
def __repr__(self):
return f'<Comment {self.id} on Task {self.task_id}>'
def can_user_view(self, user):
"""Check if a user can view this comment based on visibility settings"""
# First check if user can access the task
if not self.task.can_user_access(user):
return False
# Then check visibility settings
if self.visibility == CommentVisibility.TEAM:
# Check if user is in the same team as the task's project
if self.task.project.team_id:
return user.team_id == self.task.project.team_id
# If no team assigned to project, fall back to company visibility
return user.company_id == self.task.project.company_id
elif self.visibility == CommentVisibility.COMPANY:
# Check if user is in the same company
return user.company_id == self.task.project.company_id
return False
def can_user_edit(self, user):
"""Check if a user can edit this comment"""
# Only the comment creator can edit their own comments
return user.id == self.created_by_id
def can_user_delete(self, user):
"""Check if a user can delete this comment"""
# Comment creator can delete their own comments
if user.id == self.created_by_id:
return True
# Admins and supervisors can delete any comment in their company
if user.role in [Role.ADMIN, Role.SUPERVISOR]:
return user.company_id == self.task.project.company_id
return False
# Announcement model for system-wide announcements # Announcement model for system-wide announcements
class Announcement(db.Model): class Announcement(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)

View File

@@ -2396,7 +2396,8 @@ input[type="time"]::-webkit-datetime-edit {
/* User Dropdown Styles */ /* User Dropdown Styles */
.user-dropdown-toggle { .user-dropdown-toggle {
display: block; display: flex;
align-items: center;
padding: 0.5rem 1.25rem; padding: 0.5rem 1.25rem;
color: #495057; color: #495057;
text-decoration: none; text-decoration: none;
@@ -2412,9 +2413,7 @@ input[type="time"]::-webkit-datetime-edit {
color: #333; color: #333;
} }
.user-dropdown-toggle .nav-icon { /* Removed nav-icon style as we're using avatar instead */
margin-right: 1rem;
}
.sidebar.collapsed .user-dropdown-toggle .nav-text { .sidebar.collapsed .user-dropdown-toggle .nav-text {
display: none; display: none;
@@ -2464,6 +2463,7 @@ input[type="time"]::-webkit-datetime-edit {
color: #333; color: #333;
border-radius: 4px 4px 0 0; border-radius: 4px 4px 0 0;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0;
text-align: center;
} }
.user-dropdown-header h3 { .user-dropdown-header h3 {
@@ -2544,6 +2544,40 @@ input[type="time"]::-webkit-datetime-edit {
} }
} }
/* User Avatar Styles */
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
margin-right: 0.75rem;
border: 2px solid #e9ecef;
vertical-align: middle;
}
.user-avatar-large {
width: 64px;
height: 64px;
border-radius: 50%;
object-fit: cover;
margin: 0 auto 0.75rem;
display: block;
border: 3px solid #dee2e6;
}
.sidebar.collapsed .user-avatar {
margin-right: 0;
width: 28px;
height: 28px;
}
.user-dropdown-toggle .nav-text {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
}
/* Password Strength Indicator Styles */ /* Password Strength Indicator Styles */
.password-strength-container { .password-strength-container {
margin-top: 0.5rem; margin-top: 0.5rem;

View File

View File

@@ -40,13 +40,14 @@
{% if g.user %} {% if g.user %}
<!-- User Account Menu --> <!-- User Account Menu -->
<a href="#" class="user-dropdown-toggle" id="user-dropdown-toggle" data-tooltip="{{ g.user.username }}"> <a href="#" class="user-dropdown-toggle" id="user-dropdown-toggle" data-tooltip="{{ g.user.username }}">
<i class="nav-icon">👤</i> <img src="{{ g.user.get_avatar_url(32) }}" alt="{{ g.user.username }}" class="user-avatar">
<span class="nav-text">{{ g.user.username }}<span class="dropdown-arrow"></span></span> <span class="nav-text">{{ g.user.username }}<span class="dropdown-arrow"></span></span>
</a> </a>
<!-- User Dropdown Context Menu --> <!-- User Dropdown Context Menu -->
<div class="user-dropdown-modal" id="user-dropdown-modal"> <div class="user-dropdown-modal" id="user-dropdown-modal">
<div class="user-dropdown-header"> <div class="user-dropdown-header">
<img src="{{ g.user.get_avatar_url(64) }}" alt="{{ g.user.username }}" class="user-avatar-large">
<h3>{{ g.user.username }}</h3> <h3>{{ g.user.username }}</h3>
<div class="user-info"> <div class="user-info">
{% if g.user.email %} {% if g.user.email %}

View File

@@ -12,162 +12,403 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<div class="profile-info"> <div class="profile-grid">
<p><strong>Username:</strong> {{ user.username }}</p> <!-- Avatar Card -->
<p><strong>Account Type:</strong> {{ user.role.value if user.role else 'Team Member' }}</p> <div class="profile-card avatar-card">
<p><strong>Member Since:</strong> {{ user.created_at.strftime('%Y-%m-%d') }}</p> <h3>Profile Picture</h3>
<p><strong>Two-Factor Authentication:</strong> <div class="avatar-section">
{% if user.two_factor_enabled %} <img src="{{ user.get_avatar_url(128) }}" alt="{{ user.username }}" class="profile-avatar" id="avatar-preview">
<span class="status enabled">✅ Enabled</span> <div class="avatar-info">
{% else %} <p><strong>{{ user.username }}</strong></p>
<span class="status disabled">❌ Disabled</span> <p class="text-muted">{{ user.role.value if user.role else 'Team Member' }}</p>
{% endif %} </div>
</p>
</div>
<h2>Profile Settings</h2>
<div class="profile-card">
<h3>Basic Information</h3>
<form method="POST" action="{{ url_for('profile') }}" class="profile-form">
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" class="form-control" value="{{ user.email }}" required>
<small>This email address is used for account verification and notifications.</small>
</div> </div>
<div class="form-group"> <div class="avatar-controls">
<button type="submit" class="btn btn-primary">Update Email</button> <h4>Change Avatar</h4>
</div> <div class="avatar-options">
</form> <div class="avatar-option">
</div> <input type="radio" id="avatar-default" name="avatar-type" value="default" checked>
<label for="avatar-default">Default Avatar</label>
<div class="profile-card">
<h3>Change Password</h3>
<p>Update your account password to keep your account secure.</p>
<form method="POST" action="{{ url_for('profile') }}" class="password-form">
<!-- Hidden email field to maintain current email -->
<input type="hidden" name="email" value="{{ user.email }}">
<div class="form-group">
<label for="current_password">Current Password</label>
<input type="password" id="current_password" name="current_password" class="form-control" required>
<small>Enter your current password to verify your identity.</small>
</div>
<div class="form-group">
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password" class="form-control" required>
<small>Choose a strong password with at least 8 characters.</small>
</div>
<div class="form-group">
<label for="confirm_password">Confirm New Password</label>
<input type="password" id="confirm_password" name="confirm_password" class="form-control" required>
<small>Re-enter your new password to confirm.</small>
</div>
<div class="form-group">
<button type="submit" class="btn btn-warning">Change Password</button>
</div>
</form>
</div>
<div class="security-section">
<h2>Security Settings</h2>
<div class="security-card">
<h3>Two-Factor Authentication</h3>
{% if user.two_factor_enabled %}
<p>Two-factor authentication is <strong>enabled</strong> for your account. This adds an extra layer of security by requiring a code from your authenticator app when logging in.</p>
<form method="POST" action="{{ url_for('disable_2fa') }}" class="disable-2fa-form" onsubmit="return confirm('Are you sure you want to disable two-factor authentication? This will make your account less secure.');">
<div class="form-group">
<label for="password_disable">Enter your password to disable 2FA:</label>
<input type="password" id="password_disable" name="password" class="form-control" required>
</div> </div>
<button type="submit" class="btn btn-danger">Disable Two-Factor Authentication</button> <div class="avatar-option">
</form> <input type="radio" id="avatar-upload" name="avatar-type" value="upload">
{% else %} <label for="avatar-upload">Upload Image</label>
<p>Two-factor authentication is <strong>not enabled</strong> for your account. We strongly recommend enabling it to protect your account.</p> </div>
<p>With 2FA enabled, you'll need both your password and a code from your phone to log in.</p> <div class="avatar-option">
<input type="radio" id="avatar-url" name="avatar-type" value="url">
<label for="avatar-url">Custom URL</label>
</div>
</div>
<a href="{{ url_for('setup_2fa') }}" class="btn btn-success">Enable Two-Factor Authentication</a> <!-- Default Avatar Options -->
{% endif %} <div id="default-avatar-options" class="avatar-option-panel">
<p class="help-text">Your default avatar is generated based on your username.</p>
<button type="button" class="btn btn-secondary" onclick="resetAvatar()">Use Default Avatar</button>
</div>
<!-- Upload Avatar Options -->
<div id="upload-avatar-options" class="avatar-option-panel" style="display: none;">
<form method="POST" action="{{ url_for('upload_avatar') }}" enctype="multipart/form-data" class="avatar-upload-form">
<div class="form-group">
<label for="avatar_file" class="file-upload-label">
<span class="upload-icon">📁</span>
<span class="upload-text">Choose an image file</span>
<span class="file-name" id="file-name">No file selected</span>
</label>
<input type="file" id="avatar_file" name="avatar_file" class="file-input"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp" required>
<small>Max file size: 5MB. Supported formats: JPG, PNG, GIF, WebP</small>
</div>
<div class="upload-preview" id="upload-preview" style="display: none;">
<img id="upload-preview-img" src="" alt="Preview">
</div>
<button type="submit" class="btn btn-primary" id="upload-btn" disabled>Upload Avatar</button>
</form>
</div>
<!-- URL Avatar Options -->
<div id="url-avatar-options" class="avatar-option-panel" style="display: none;">
<form method="POST" action="{{ url_for('update_avatar') }}" class="avatar-form">
<div class="form-group">
<label for="avatar_url">Avatar URL</label>
<input type="url" id="avatar_url" name="avatar_url" class="form-control"
placeholder="https://example.com/avatar.jpg"
value="{{ user.avatar_url or '' }}">
<small>Enter a direct link to an image (PNG, JPG, GIF)</small>
</div>
<button type="submit" class="btn btn-primary">Update Avatar</button>
</form>
</div>
</div>
</div>
<!-- Account Info Card -->
<div class="profile-card">
<h3>Account Information</h3>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Username</span>
<span class="info-value">{{ user.username }}</span>
</div>
<div class="info-item">
<span class="info-label">Email</span>
<span class="info-value">{{ user.email }}</span>
</div>
<div class="info-item">
<span class="info-label">Role</span>
<span class="info-value">{{ user.role.value if user.role else 'Team Member' }}</span>
</div>
<div class="info-item">
<span class="info-label">Company</span>
<span class="info-value">{{ user.company.name if user.company else 'N/A' }}</span>
</div>
<div class="info-item">
<span class="info-label">Team</span>
<span class="info-value">{{ user.team.name if user.team else 'No Team' }}</span>
</div>
<div class="info-item">
<span class="info-label">Member Since</span>
<span class="info-value">{{ user.created_at.strftime('%B %d, %Y') }}</span>
</div>
</div>
</div>
<!-- Email Settings Card -->
<div class="profile-card">
<h3>Email Settings</h3>
<form method="POST" action="{{ url_for('profile') }}" class="profile-form">
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" class="form-control" value="{{ user.email }}" required>
<small>This email address is used for account verification and notifications.</small>
</div>
<button type="submit" class="btn btn-primary">Update Email</button>
</form>
</div>
<!-- Password Settings Card -->
<div class="profile-card">
<h3>Change Password</h3>
<form method="POST" action="{{ url_for('profile') }}" class="password-form">
<!-- Hidden email field to maintain current email -->
<input type="hidden" name="email" value="{{ user.email }}">
<div class="form-group">
<label for="current_password">Current Password</label>
<input type="password" id="current_password" name="current_password" class="form-control" required>
</div>
<div class="form-group">
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password" class="form-control" required>
<small>Choose a strong password with at least 8 characters.</small>
</div>
<div class="form-group">
<label for="confirm_password">Confirm New Password</label>
<input type="password" id="confirm_password" name="confirm_password" class="form-control" required>
</div>
<button type="submit" class="btn btn-warning">Change Password</button>
</form>
</div>
<!-- Security Settings Card -->
<div class="profile-card security-card">
<h3>Two-Factor Authentication</h3>
<div class="security-status">
{% if user.two_factor_enabled %}
<div class="status-badge enabled">
<span class="status-icon"></span>
<span>Enabled</span>
</div>
<p>Two-factor authentication adds an extra layer of security to your account.</p>
<form method="POST" action="{{ url_for('disable_2fa') }}" class="disable-2fa-form"
onsubmit="return confirm('Are you sure you want to disable two-factor authentication?');">
<div class="form-group">
<label for="password_disable">Enter your password to disable 2FA:</label>
<input type="password" id="password_disable" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-danger">Disable 2FA</button>
</form>
{% else %}
<div class="status-badge disabled">
<span class="status-icon"></span>
<span>Disabled</span>
</div>
<p>Enable two-factor authentication to add an extra layer of security to your account.</p>
<a href="{{ url_for('setup_2fa') }}" class="btn btn-success">Enable 2FA</a>
{% endif %}
</div>
</div> </div>
</div> </div>
</div> </div>
<style> <style>
.status.enabled { .profile-container {
color: #28a745; max-width: 1200px;
font-weight: bold; margin: 0 auto;
padding: 2rem;
} }
.status.disabled { .profile-grid {
color: #dc3545; display: grid;
font-weight: bold; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
} }
.profile-card { .profile-card {
background: #f8f9fa; background: white;
border: 1px solid #dee2e6; border: 1px solid #e9ecef;
border-radius: 0.5rem; border-radius: 8px;
padding: 1.5rem; padding: 1.5rem;
margin: 1.5rem 0; box-shadow: 0 2px 4px rgba(0,0,0,0.05);
} }
.profile-card h3 { .profile-card h3 {
color: #007bff; color: #333;
margin-bottom: 1rem;
}
.profile-card p {
color: #6c757d;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
font-size: 1.25rem;
font-weight: 600;
padding-bottom: 0.75rem;
border-bottom: 1px solid #e9ecef;
} }
.security-section { .profile-card h4 {
margin-top: 2rem; color: #495057;
padding-top: 2rem; font-size: 1rem;
border-top: 1px solid #dee2e6; font-weight: 600;
margin: 1.5rem 0 1rem;
} }
.security-card { /* Avatar Section */
background: #f8f9fa; .avatar-card {
border: 1px solid #dee2e6; grid-column: span 2;
border-radius: 0.5rem;
padding: 1.5rem;
margin: 1rem 0;
} }
.security-card h3 { .avatar-section {
color: #007bff; display: flex;
align-items: center;
gap: 2rem;
margin-bottom: 2rem;
}
.profile-avatar {
width: 128px;
height: 128px;
border-radius: 50%;
object-fit: cover;
border: 4px solid #e9ecef;
}
.avatar-info {
flex: 1;
}
.avatar-info p {
margin: 0.25rem 0;
}
.text-muted {
color: #6c757d;
font-size: 0.9rem;
}
.avatar-controls {
border-top: 1px solid #e9ecef;
padding-top: 1.5rem;
}
.avatar-options {
display: flex;
gap: 2rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.avatar-option {
display: flex;
align-items: center;
gap: 0.5rem;
}
.avatar-option input[type="radio"] {
cursor: pointer;
}
.avatar-option label {
cursor: pointer;
margin-bottom: 0;
}
.avatar-option-panel {
margin-top: 1rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 4px;
}
.help-text {
color: #6c757d;
font-size: 0.9rem;
margin-bottom: 1rem;
}
/* File Upload Styles */
.file-upload-label {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border: 2px dashed #dee2e6;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
background: #f8f9fa;
}
.file-upload-label:hover {
border-color: #007bff;
background: #e7f3ff;
}
.upload-icon {
font-size: 1.5rem;
}
.upload-text {
flex: 1;
font-weight: 500;
color: #495057;
}
.file-name {
font-size: 0.875rem;
color: #6c757d;
}
.file-input {
display: none;
}
.upload-preview {
margin: 1rem 0;
text-align: center;
}
.upload-preview img {
max-width: 200px;
max-height: 200px;
border-radius: 8px;
border: 2px solid #dee2e6;
}
/* Account Info Grid */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.info-label {
font-size: 0.875rem;
color: #6c757d;
font-weight: 500;
}
.info-value {
font-size: 1rem;
color: #333;
}
/* Security Status */
.security-status {
text-align: center;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 4px;
font-weight: 600;
margin-bottom: 1rem;
}
.status-badge.enabled {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-badge.disabled {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.status-icon {
font-size: 1.25rem;
}
.disable-2fa-form { .disable-2fa-form {
margin-top: 1rem; margin-top: 1rem;
padding: 1rem; padding: 1rem;
background: #fff3cd; background: #fff3cd;
border: 1px solid #ffeaa7; border: 1px solid #ffeaa7;
border-radius: 0.25rem; border-radius: 4px;
} }
.btn { /* Form Styles */
display: inline-block;
padding: 0.75rem 1.5rem;
margin: 0.5rem 0;
border: none;
border-radius: 0.25rem;
text-decoration: none;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
}
/* Button styles now centralized in main style.css */
.form-group { .form-group {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
@@ -183,9 +424,8 @@
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid #ced4da; border: 1px solid #ced4da;
border-radius: 0.25rem; border-radius: 4px;
font-size: 1rem; font-size: 1rem;
line-height: 1.5;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
} }
@@ -201,5 +441,146 @@
color: #6c757d; color: #6c757d;
font-size: 0.875rem; font-size: 0.875rem;
} }
/* Mobile Responsiveness */
@media (max-width: 768px) {
.profile-grid {
grid-template-columns: 1fr;
}
.avatar-card {
grid-column: span 1;
}
.avatar-section {
flex-direction: column;
text-align: center;
}
.avatar-options {
justify-content: center;
}
}
</style> </style>
<script>
// Avatar type toggle
document.addEventListener('DOMContentLoaded', function() {
const avatarTypeRadios = document.querySelectorAll('input[name="avatar-type"]');
const defaultPanel = document.getElementById('default-avatar-options');
const uploadPanel = document.getElementById('upload-avatar-options');
const urlPanel = document.getElementById('url-avatar-options');
const avatarUrlInput = document.getElementById('avatar_url');
const avatarPreview = document.getElementById('avatar-preview');
const fileInput = document.getElementById('avatar_file');
const fileName = document.getElementById('file-name');
const uploadPreview = document.getElementById('upload-preview');
const uploadPreviewImg = document.getElementById('upload-preview-img');
const uploadBtn = document.getElementById('upload-btn');
avatarTypeRadios.forEach(radio => {
radio.addEventListener('change', function() {
// Hide all panels
defaultPanel.style.display = 'none';
uploadPanel.style.display = 'none';
urlPanel.style.display = 'none';
// Show selected panel
if (this.value === 'default') {
defaultPanel.style.display = 'block';
} else if (this.value === 'upload') {
uploadPanel.style.display = 'block';
} else if (this.value === 'url') {
urlPanel.style.display = 'block';
}
});
});
// File input handling
fileInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
// Update file name display
fileName.textContent = file.name;
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
alert('File size must be less than 5MB');
this.value = '';
fileName.textContent = 'No file selected';
uploadPreview.style.display = 'none';
uploadBtn.disabled = true;
return;
}
// Validate file type
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
alert('Please select a valid image file (JPG, PNG, GIF, or WebP)');
this.value = '';
fileName.textContent = 'No file selected';
uploadPreview.style.display = 'none';
uploadBtn.disabled = true;
return;
}
// Preview the image
const reader = new FileReader();
reader.onload = function(e) {
uploadPreviewImg.src = e.target.result;
uploadPreview.style.display = 'block';
uploadBtn.disabled = false;
};
reader.readAsDataURL(file);
} else {
fileName.textContent = 'No file selected';
uploadPreview.style.display = 'none';
uploadBtn.disabled = true;
}
});
// Preview avatar URL
avatarUrlInput.addEventListener('input', function() {
const url = this.value.trim();
if (url && isValidUrl(url)) {
// Test if image loads
const img = new Image();
img.onload = function() {
avatarPreview.src = url;
};
img.onerror = function() {
// Keep current avatar if URL is invalid
avatarPreview.src = '{{ user.get_avatar_url(128) }}';
};
img.src = url;
}
});
});
function isValidUrl(string) {
try {
new URL(string);
return true;
} catch (_) {
return false;
}
}
function resetAvatar() {
if (confirm('This will remove your custom avatar and use the default generated avatar. Continue?')) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '{{ url_for("update_avatar") }}';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'avatar_url';
input.value = '';
form.appendChild(input);
document.body.appendChild(form);
form.submit();
}
}
</script>
{% endblock %} {% endblock %}

View File

@@ -135,6 +135,24 @@
<button type="button" class="btn btn-sm btn-secondary" onclick="addSubtask()">+ Add Subtask</button> <button type="button" class="btn btn-sm btn-secondary" onclick="addSubtask()">+ Add Subtask</button>
</div> </div>
</form> </form>
<!-- Comments Section (outside form) -->
<div class="form-section" id="comments-section" style="display: none;">
<h3>💬 Comments</h3>
<div id="comments-container">
<!-- Comments will be populated here -->
</div>
<div class="comment-form">
<textarea id="new-comment" placeholder="Add a comment..." rows="2"></textarea>
<div class="comment-form-actions">
<select id="comment-visibility" class="comment-visibility-select" style="display: none;">
<option value="COMPANY">🏢 Company</option>
<option value="TEAM">👥 Team Only</option>
</select>
<button type="button" class="btn btn-sm btn-primary" onclick="addComment()">Post Comment</button>
</div>
</div>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeTaskModal()">Cancel</button> <button type="button" class="btn btn-secondary" onclick="closeTaskModal()">Cancel</button>
@@ -540,4 +558,183 @@ document.addEventListener('DOMContentLoaded', function() {
padding: 0.15rem 0.3rem; padding: 0.15rem 0.3rem;
} }
} }
/* Comments Section Styles */
#comments-container {
max-height: 300px;
overflow-y: auto;
margin-bottom: 1rem;
}
.no-comments {
text-align: center;
color: #6c757d;
font-style: italic;
padding: 2rem;
}
.comment-edited {
font-size: 0.75rem;
color: #6c757d;
font-style: italic;
}
.comment-item {
background: #f8f9fa;
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 0.5rem;
border: 1px solid #e9ecef;
}
.comment-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.comment-author-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.comment-author-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
}
.comment-author-details {
display: flex;
flex-direction: column;
}
.comment-author {
font-weight: 600;
font-size: 0.85rem;
color: #333;
}
.comment-time {
font-size: 0.75rem;
color: #6c757d;
}
.comment-visibility-badge {
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
border-radius: 4px;
background: #e9ecef;
color: #6c757d;
}
.comment-visibility-badge.team {
background: #fff3cd;
color: #856404;
}
.comment-content {
font-size: 0.85rem;
line-height: 1.5;
color: #495057;
white-space: pre-wrap;
}
.comment-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.comment-action {
font-size: 0.75rem;
color: #6c757d;
background: none;
border: none;
cursor: pointer;
padding: 0.2rem 0.4rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.comment-action:hover {
background: #e9ecef;
}
.comment-form {
background: #f8f9fa;
border-radius: 8px;
padding: 0.75rem;
border: 1px solid #e9ecef;
}
#new-comment {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
resize: vertical;
min-height: 60px;
font-size: 0.85rem;
}
.comment-form-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.comment-visibility-select {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
}
/* Edit comment form */
.comment-edit-form {
margin-top: 0.5rem;
display: none;
}
.comment-edit-textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
resize: vertical;
min-height: 60px;
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.comment-edit-actions {
display: flex;
gap: 0.5rem;
}
/* Reply form */
.comment-reply-form {
margin-top: 0.5rem;
padding-left: 2rem;
display: none;
}
.comment-replies {
margin-top: 0.5rem;
padding-left: 2rem;
}
.comment-reply {
background: white;
border-radius: 6px;
padding: 0.5rem;
margin-bottom: 0.3rem;
border: 1px solid #e9ecef;
}
</style> </style>

View File

@@ -6,7 +6,6 @@
<div class="management-header task-header"> <div class="management-header task-header">
<h1>📋 Task Management</h1> <h1>📋 Task Management</h1>
<div class="management-controls task-controls"> <div class="management-controls task-controls">
<!-- Smart Search --> <!-- Smart Search -->
<div class="smart-search-container"> <div class="smart-search-container">
<div class="smart-search-box"> <div class="smart-search-box">
@@ -234,16 +233,64 @@
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
width: 100%;
} }
.task-controls .smart-search-container { .task-controls .smart-search-container {
flex: 1; flex: 1;
min-width: 300px; min-width: 300px;
max-width: 600px; max-width: 600px;
margin-bottom: 0; /* Remove margin to align with buttons */
} }
.task-controls .management-actions { .task-controls .management-actions {
flex-shrink: 0; flex-shrink: 0;
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Ensure all buttons and search input have same height */
.smart-search-input,
.task-controls .btn {
height: 38px; /* Standard height for consistency */
}
.task-controls .btn {
padding: 0.5rem 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap; /* Prevent button text from wrapping */
}
/* Responsive adjustments */
@media (max-width: 992px) {
.task-controls {
flex-direction: column;
align-items: stretch;
}
.task-controls .smart-search-container {
max-width: 100%;
margin-bottom: 0.5rem;
}
.task-controls .management-actions {
justify-content: center;
}
}
@media (max-width: 576px) {
.task-controls .management-actions {
flex-wrap: wrap;
gap: 0.25rem;
}
.task-controls .btn {
font-size: 0.875rem;
padding: 0.4rem 0.8rem;
}
} }
/* Subtask progress styles */ /* Subtask progress styles */
@@ -371,7 +418,6 @@
/* Task Action Buttons */ /* Task Action Buttons */
.task-actions { .task-actions {
margin-top: 0.5rem;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 0.25rem; gap: 0.25rem;
@@ -487,6 +533,17 @@
.task-assignee { .task-assignee {
font-weight: 500; font-weight: 500;
display: flex;
align-items: center;
gap: 0.25rem;
}
.task-assignee-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #e9ecef;
} }
.task-due-date { .task-due-date {
@@ -1022,6 +1079,29 @@ class UnifiedTaskManager {
await this.loadTasks(); await this.loadTasks();
} }
getUserAvatar(userId) {
// Find user in team members
const user = window.teamMembers.find(member => member.id === userId);
if (user && user.avatar_url) {
return user.avatar_url;
}
// Generate default avatar using DiceBear API
const username = user ? user.username : `user_${userId}`;
const hash = this.hashCode(username + '_' + userId);
return `https://api.dicebear.com/7.x/initials/svg?seed=${hash}&size=24&backgroundColor=ffffff`;
}
hashCode(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(16);
}
setupEventListeners() { setupEventListeners() {
// Smart Search // Smart Search
this.setupSmartSearch(); this.setupSmartSearch();
@@ -1568,7 +1648,10 @@ class UnifiedTaskManager {
</div> </div>
${task.project_name ? `<div class="task-project">${task.project_code} - ${task.project_name}</div>` : ''} ${task.project_name ? `<div class="task-project">${task.project_code} - ${task.project_name}</div>` : ''}
<div class="task-meta"> <div class="task-meta">
<span class="task-assignee">${task.assigned_to_name || 'Unassigned'}</span> <span class="task-assignee">
${task.assigned_to_id ? `<img src="${this.getUserAvatar(task.assigned_to_id)}" alt="${task.assigned_to_name}" class="task-assignee-avatar">` : ''}
${task.assigned_to_name || 'Unassigned'}
</span>
${dueDate ? `<span class="task-due-date ${isOverdue ? 'overdue' : ''}">${formatUserDate(task.due_date)}</span>` : ''} ${dueDate ? `<span class="task-due-date ${isOverdue ? 'overdue' : ''}">${formatUserDate(task.due_date)}</span>` : ''}
</div> </div>
${task.subtasks && task.subtasks.length > 0 ? this.renderSubtaskProgress(task.subtasks) : ''} ${task.subtasks && task.subtasks.length > 0 ? this.renderSubtaskProgress(task.subtasks) : ''}
@@ -1769,6 +1852,10 @@ class UnifiedTaskManager {
// Initialize subtasks // Initialize subtasks
initializeSubtasks(task.id); initializeSubtasks(task.id);
// Show comments section and load comments
document.getElementById('comments-section').style.display = 'block';
await this.loadComments(task.id);
} else { } else {
document.getElementById('modal-title').textContent = 'Add New Task'; document.getElementById('modal-title').textContent = 'Add New Task';
document.getElementById('task-form').reset(); document.getElementById('task-form').reset();
@@ -1780,6 +1867,9 @@ class UnifiedTaskManager {
// Initialize empty subtasks // Initialize empty subtasks
initializeSubtasks(null); initializeSubtasks(null);
// Hide comments section for new tasks
document.getElementById('comments-section').style.display = 'none';
} }
modal.style.display = 'block'; modal.style.display = 'block';
@@ -1988,6 +2078,320 @@ class UnifiedTaskManager {
} }
} }
} }
// Comment management methods
async loadComments(taskId) {
try {
const response = await fetch(`/api/tasks/${taskId}/comments`);
const data = await response.json();
if (data.success) {
this.renderComments(data.comments);
// Show/hide team visibility option based on company settings
const visibilitySelect = document.getElementById('comment-visibility');
if (data.allow_team_visibility) {
visibilitySelect.style.display = 'inline-block';
} else {
visibilitySelect.style.display = 'none';
}
} else {
console.error('Failed to load comments:', data.message);
}
} catch (error) {
console.error('Error loading comments:', error);
}
}
renderComments(comments) {
const container = document.getElementById('comments-container');
container.innerHTML = '';
if (comments.length === 0) {
container.innerHTML = '<p class="no-comments">No comments yet. Be the first to comment!</p>';
return;
}
comments.forEach(comment => {
const commentElement = this.createCommentElement(comment);
container.appendChild(commentElement);
});
}
createCommentElement(comment) {
const element = document.createElement('div');
element.className = 'comment-item';
element.dataset.commentId = comment.id;
const visibilityBadge = comment.visibility === 'Team' ?
'<span class="comment-visibility-badge team">👥 Team</span>' : '';
const editedText = comment.is_edited ?
` <span class="comment-edited">(edited)</span>` : '';
element.innerHTML = `
<div class="comment-header">
<div class="comment-author-info">
<img src="${comment.author.avatar_url}" alt="${comment.author.username}" class="comment-author-avatar">
<div class="comment-author-details">
<span class="comment-author">${comment.author.username}</span>
<span class="comment-time">${this.formatRelativeTime(comment.created_at)}${editedText}</span>
</div>
</div>
${visibilityBadge}
</div>
<div class="comment-content">${this.escapeHtml(comment.content)}</div>
<div class="comment-actions">
${comment.can_edit ? '<button class="comment-action" onclick="taskManager.editComment(' + comment.id + ')">Edit</button>' : ''}
${comment.can_delete ? '<button class="comment-action" onclick="taskManager.deleteComment(' + comment.id + ')">Delete</button>' : ''}
<button class="comment-action" onclick="taskManager.replyToComment(${comment.id})">Reply</button>
</div>
<div class="comment-edit-form" id="comment-edit-${comment.id}" style="display: none;">
<textarea class="comment-edit-textarea">${this.escapeHtml(comment.content)}</textarea>
<div class="comment-edit-actions">
<button class="btn btn-sm btn-primary" onclick="taskManager.saveCommentEdit(${comment.id})">Save</button>
<button class="btn btn-sm btn-secondary" onclick="taskManager.cancelCommentEdit(${comment.id})">Cancel</button>
</div>
</div>
<div class="comment-reply-form" id="comment-reply-${comment.id}" style="display: none;">
<textarea placeholder="Write a reply..." rows="2"></textarea>
<div class="comment-edit-actions">
<button class="btn btn-sm btn-primary" onclick="taskManager.saveReply(${comment.id})">Reply</button>
<button class="btn btn-sm btn-secondary" onclick="taskManager.cancelReply(${comment.id})">Cancel</button>
</div>
</div>
`;
// Add replies if any
if (comment.replies && comment.replies.length > 0) {
const repliesContainer = document.createElement('div');
repliesContainer.className = 'comment-replies';
comment.replies.forEach(reply => {
const replyElement = this.createReplyElement(reply);
repliesContainer.appendChild(replyElement);
});
element.appendChild(repliesContainer);
}
return element;
}
createReplyElement(reply) {
const element = document.createElement('div');
element.className = 'comment-reply';
element.dataset.commentId = reply.id;
const editedText = reply.is_edited ?
` <span class="comment-edited">(edited)</span>` : '';
element.innerHTML = `
<div class="comment-header">
<div class="comment-author-info">
<img src="${reply.author.avatar_url}" alt="${reply.author.username}" class="comment-author-avatar">
<div class="comment-author-details">
<span class="comment-author">${reply.author.username}</span>
<span class="comment-time">${this.formatRelativeTime(reply.created_at)}${editedText}</span>
</div>
</div>
</div>
<div class="comment-content">${this.escapeHtml(reply.content)}</div>
<div class="comment-actions">
${reply.can_edit ? '<button class="comment-action" onclick="taskManager.editComment(' + reply.id + ')">Edit</button>' : ''}
${reply.can_delete ? '<button class="comment-action" onclick="taskManager.deleteComment(' + reply.id + ')">Delete</button>' : ''}
</div>
`;
return element;
}
async addComment() {
const taskId = document.getElementById('task-id').value;
const contentTextarea = document.getElementById('new-comment');
const content = contentTextarea.value.trim();
const visibility = document.getElementById('comment-visibility').value;
if (!taskId || !content) {
alert('Please enter a comment');
return;
}
try {
const response = await fetch(`/api/tasks/${taskId}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: content,
visibility: visibility
})
});
const data = await response.json();
if (data.success) {
contentTextarea.value = '';
await this.loadComments(taskId);
} else {
alert('Failed to post comment: ' + data.message);
}
} catch (error) {
console.error('Error posting comment:', error);
alert('Failed to post comment: ' + error.message);
}
}
editComment(commentId) {
const commentElement = document.querySelector(`[data-comment-id="${commentId}"]`);
const editForm = document.getElementById(`comment-edit-${commentId}`);
const content = commentElement.querySelector('.comment-content');
content.style.display = 'none';
editForm.style.display = 'block';
}
cancelCommentEdit(commentId) {
const editForm = document.getElementById(`comment-edit-${commentId}`);
const content = document.querySelector(`[data-comment-id="${commentId}"] .comment-content`);
content.style.display = 'block';
editForm.style.display = 'none';
}
async saveCommentEdit(commentId) {
const editForm = document.getElementById(`comment-edit-${commentId}`);
const textarea = editForm.querySelector('textarea');
const content = textarea.value.trim();
if (!content) {
alert('Comment cannot be empty');
return;
}
try {
const response = await fetch(`/api/comments/${commentId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: content
})
});
const data = await response.json();
if (data.success) {
const taskId = document.getElementById('task-id').value;
await this.loadComments(taskId);
} else {
alert('Failed to update comment: ' + data.message);
}
} catch (error) {
console.error('Error updating comment:', error);
alert('Failed to update comment: ' + error.message);
}
}
async deleteComment(commentId) {
if (!confirm('Are you sure you want to delete this comment?')) {
return;
}
try {
const response = await fetch(`/api/comments/${commentId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
const taskId = document.getElementById('task-id').value;
await this.loadComments(taskId);
} else {
alert('Failed to delete comment: ' + data.message);
}
} catch (error) {
console.error('Error deleting comment:', error);
alert('Failed to delete comment: ' + error.message);
}
}
replyToComment(commentId) {
const replyForm = document.getElementById(`comment-reply-${commentId}`);
replyForm.style.display = 'block';
replyForm.querySelector('textarea').focus();
}
cancelReply(commentId) {
const replyForm = document.getElementById(`comment-reply-${commentId}`);
replyForm.style.display = 'none';
replyForm.querySelector('textarea').value = '';
}
async saveReply(parentCommentId) {
const taskId = document.getElementById('task-id').value;
const replyForm = document.getElementById(`comment-reply-${parentCommentId}`);
const content = replyForm.querySelector('textarea').value.trim();
if (!content) {
alert('Please enter a reply');
return;
}
try {
const response = await fetch(`/api/tasks/${taskId}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: content,
parent_comment_id: parentCommentId,
visibility: 'COMPANY' // Replies inherit parent visibility
})
});
const data = await response.json();
if (data.success) {
replyForm.style.display = 'none';
replyForm.querySelector('textarea').value = '';
await this.loadComments(taskId);
} else {
alert('Failed to post reply: ' + data.message);
}
} catch (error) {
console.error('Error posting reply:', error);
alert('Failed to post reply: ' + error.message);
}
}
formatRelativeTime(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) {
return 'just now';
} else if (diffMins < 60) {
return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
} else if (diffHours < 24) {
return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
} else if (diffDays < 7) {
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
} else {
return date.toLocaleDateString();
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
} }
// Global functions // Global functions
@@ -2011,6 +2415,10 @@ function deleteTask() {
taskManager.deleteTask(); taskManager.deleteTask();
} }
function addComment() {
taskManager.addComment();
}
function addSubtask() { function addSubtask() {
// TODO: Implement subtask functionality // TODO: Implement subtask functionality
} }