diff --git a/Dockerfile b/Dockerfile index bc88908..3a1427f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,6 +38,9 @@ COPY . . # Create the SQLite database directory with proper permissions 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 RUN mkdir /data && chmod 777 /data diff --git a/app.py b/app.py index a6f0864..af74030 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ 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 ( format_duration, prepare_export_data, prepare_team_hours_export_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) +@app.route('/update-avatar', methods=['POST']) +@login_required +def update_avatar(): + """Update user avatar URL""" + user = User.query.get(session['user_id']) + avatar_url = request.form.get('avatar_url', '').strip() + + # Validate URL if provided + if avatar_url: + # Basic URL validation + import re + url_pattern = re.compile( + r'^https?://' # http:// or https:// + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' # domain... + r'localhost|' # localhost... + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip + r'(?::\d+)?' # optional port + r'(?:/?|[/?]\S+)$', re.IGNORECASE) + + if not url_pattern.match(avatar_url): + flash('Please provide a valid URL for your avatar.', 'error') + return redirect(url_for('profile')) + + # Additional validation for image URLs + allowed_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'] + if not any(avatar_url.lower().endswith(ext) for ext in allowed_extensions): + # Check if it's a service that doesn't use extensions (like gravatar) + allowed_services = ['gravatar.com', 'dicebear.com', 'ui-avatars.com', 'avatars.githubusercontent.com'] + if not any(service in avatar_url.lower() for service in allowed_services): + flash('Avatar URL should point to an image file (JPG, PNG, GIF, WebP, or SVG).', 'error') + return redirect(url_for('profile')) + + # Update avatar URL (empty string removes custom avatar) + user.avatar_url = avatar_url if avatar_url else None + db.session.commit() + + if avatar_url: + flash('Avatar updated successfully!', 'success') + else: + flash('Avatar reset to default.', 'success') + + # Log the avatar change + SystemEvent.log_event( + event_type='profile_avatar_updated', + event_category='user', + description=f'User {user.username} updated their avatar', + user_id=user.id, + company_id=user.company_id + ) + + return redirect(url_for('profile')) + +@app.route('/upload-avatar', methods=['POST']) +@login_required +def upload_avatar(): + """Handle avatar file upload""" + import os + from werkzeug.utils import secure_filename + import uuid + + user = User.query.get(session['user_id']) + + # Check if file was uploaded + if 'avatar_file' not in request.files: + flash('No file selected.', 'error') + return redirect(url_for('profile')) + + file = request.files['avatar_file'] + + # Check if file is empty + if file.filename == '': + flash('No file selected.', 'error') + return redirect(url_for('profile')) + + # Validate file extension + allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'webp'} + file_ext = file.filename.rsplit('.', 1)[1].lower() if '.' in file.filename else '' + + if file_ext not in allowed_extensions: + flash('Invalid file type. Please upload a PNG, JPG, GIF, or WebP image.', 'error') + return redirect(url_for('profile')) + + # Validate file size (5MB max) + file.seek(0, os.SEEK_END) + file_size = file.tell() + file.seek(0) # Reset file pointer + + if file_size > 5 * 1024 * 1024: # 5MB + flash('File size must be less than 5MB.', 'error') + return redirect(url_for('profile')) + + # Generate unique filename + unique_filename = f"{user.id}_{uuid.uuid4().hex}.{file_ext}" + + # Create user avatar directory if it doesn't exist + avatar_dir = os.path.join(app.static_folder, 'uploads', 'avatars') + os.makedirs(avatar_dir, exist_ok=True) + + # Save the file + file_path = os.path.join(avatar_dir, unique_filename) + file.save(file_path) + + # Delete old avatar file if it exists and is a local upload + if user.avatar_url and user.avatar_url.startswith('/static/uploads/avatars/'): + old_file_path = os.path.join(app.root_path, user.avatar_url.lstrip('/')) + if os.path.exists(old_file_path): + try: + os.remove(old_file_path) + except Exception as e: + logger.warning(f"Failed to delete old avatar: {e}") + + # Update user's avatar URL + user.avatar_url = f"/static/uploads/avatars/{unique_filename}" + db.session.commit() + + flash('Avatar uploaded successfully!', 'success') + + # Log the avatar upload + SystemEvent.log_event( + event_type='profile_avatar_uploaded', + event_category='user', + description=f'User {user.username} uploaded a new avatar', + user_id=user.id, + company_id=user.company_id + ) + + return redirect(url_for('profile')) + @app.route('/2fa/setup', methods=['GET', 'POST']) @login_required def setup_2fa(): @@ -3730,7 +3858,8 @@ def unified_task_management(): 'id': member.id, 'username': member.username, '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] return render_template('unified_task_management.html', @@ -4705,6 +4834,239 @@ def delete_subtask(subtask_id): db.session.rollback() return jsonify({'success': False, 'message': str(e)}) +# Comment API Routes +@app.route('/api/tasks//comments') +@login_required +@company_required +def get_task_comments(task_id): + """Get all comments for a task that the user can view""" + try: + task = Task.query.join(Project).filter( + Task.id == task_id, + Project.company_id == g.user.company_id + ).first() + + if not task or not task.can_user_access(g.user): + return jsonify({'success': False, 'message': 'Task not found or access denied'}) + + # Get all comments for the task + comments = [] + for comment in task.comments.order_by(Comment.created_at.desc()): + if comment.can_user_view(g.user): + comment_data = { + 'id': comment.id, + 'content': comment.content, + 'visibility': comment.visibility.value, + 'is_edited': comment.is_edited, + 'edited_at': comment.edited_at.isoformat() if comment.edited_at else None, + 'created_at': comment.created_at.isoformat(), + 'author': { + 'id': comment.created_by.id, + 'username': comment.created_by.username, + 'avatar_url': comment.created_by.get_avatar_url(40) + }, + 'can_edit': comment.can_user_edit(g.user), + 'can_delete': comment.can_user_delete(g.user), + 'replies': [] + } + + # Add replies if any + for reply in comment.replies: + if reply.can_user_view(g.user): + reply_data = { + 'id': reply.id, + 'content': reply.content, + 'is_edited': reply.is_edited, + 'edited_at': reply.edited_at.isoformat() if reply.edited_at else None, + 'created_at': reply.created_at.isoformat(), + 'author': { + 'id': reply.created_by.id, + 'username': reply.created_by.username, + 'avatar_url': reply.created_by.get_avatar_url(40) + }, + 'can_edit': reply.can_user_edit(g.user), + 'can_delete': reply.can_user_delete(g.user) + } + comment_data['replies'].append(reply_data) + + comments.append(comment_data) + + # Check if user can use team visibility + company_settings = CompanySettings.query.filter_by(company_id=g.user.company_id).first() + allow_team_visibility = company_settings.allow_team_visibility_comments if company_settings else True + + return jsonify({ + 'success': True, + 'comments': comments, + 'allow_team_visibility': allow_team_visibility + }) + + except Exception as e: + return jsonify({'success': False, 'message': str(e)}) + +@app.route('/api/tasks//comments', methods=['POST']) +@login_required +@company_required +def create_task_comment(task_id): + """Create a new comment on a task""" + try: + task = Task.query.join(Project).filter( + Task.id == task_id, + Project.company_id == g.user.company_id + ).first() + + if not task or not task.can_user_access(g.user): + return jsonify({'success': False, 'message': 'Task not found or access denied'}) + + data = request.get_json() + content = data.get('content', '').strip() + visibility = data.get('visibility', 'COMPANY') + parent_comment_id = data.get('parent_comment_id') + + if not content: + return jsonify({'success': False, 'message': 'Comment content is required'}) + + # Check visibility settings + company_settings = CompanySettings.query.filter_by(company_id=g.user.company_id).first() + if visibility == 'TEAM' and company_settings and not company_settings.allow_team_visibility_comments: + visibility = 'COMPANY' + + # Validate parent comment if provided + if parent_comment_id: + parent_comment = Comment.query.filter_by( + id=parent_comment_id, + task_id=task_id + ).first() + + if not parent_comment or not parent_comment.can_user_view(g.user): + return jsonify({'success': False, 'message': 'Parent comment not found or access denied'}) + + # Create comment + comment = Comment( + content=content, + task_id=task_id, + parent_comment_id=parent_comment_id, + visibility=CommentVisibility[visibility], + created_by_id=g.user.id + ) + + db.session.add(comment) + db.session.commit() + + # Log system event + SystemEvent.log_event( + event_type='comment_created', + event_category='task', + description=f'Comment added to task {task.task_number}', + user_id=g.user.id, + company_id=g.user.company_id, + event_metadata={'task_id': task_id, 'comment_id': comment.id} + ) + + # Return the created comment + comment_data = { + 'id': comment.id, + 'content': comment.content, + 'visibility': comment.visibility.value, + 'is_edited': comment.is_edited, + 'created_at': comment.created_at.isoformat(), + 'author': { + 'id': comment.created_by.id, + 'username': comment.created_by.username, + 'avatar_url': comment.created_by.get_avatar_url(40) + }, + 'can_edit': True, + 'can_delete': True + } + + return jsonify({ + 'success': True, + 'message': 'Comment posted successfully', + 'comment': comment_data + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'message': str(e)}) + +@app.route('/api/comments/', methods=['PUT']) +@login_required +@company_required +def update_comment(comment_id): + """Update an existing comment""" + try: + comment = Comment.query.join(Task).join(Project).filter( + Comment.id == comment_id, + Project.company_id == g.user.company_id + ).first() + + if not comment: + return jsonify({'success': False, 'message': 'Comment not found'}) + + if not comment.can_user_edit(g.user): + return jsonify({'success': False, 'message': 'You cannot edit this comment'}) + + data = request.get_json() + content = data.get('content', '').strip() + + if not content: + return jsonify({'success': False, 'message': 'Comment content is required'}) + + comment.content = content + comment.is_edited = True + comment.edited_at = datetime.now() + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Comment updated successfully', + 'edited_at': comment.edited_at.isoformat() + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'message': str(e)}) + +@app.route('/api/comments/', methods=['DELETE']) +@login_required +@company_required +def delete_comment(comment_id): + """Delete a comment""" + try: + comment = Comment.query.join(Task).join(Project).filter( + Comment.id == comment_id, + Project.company_id == g.user.company_id + ).first() + + if not comment: + return jsonify({'success': False, 'message': 'Comment not found'}) + + if not comment.can_user_delete(g.user): + return jsonify({'success': False, 'message': 'You cannot delete this comment'}) + + # Log system event before deletion + SystemEvent.log_event( + event_type='comment_deleted', + event_category='task', + description=f'Comment deleted from task {comment.task.task_number}', + user_id=g.user.id, + company_id=g.user.company_id, + event_metadata={'task_id': comment.task_id, 'comment_id': comment.id} + ) + + db.session.delete(comment) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'Comment deleted successfully' + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'message': str(e)}) + # Category Management API Routes @app.route('/api/admin/categories', methods=['POST']) @role_required(Role.ADMIN) diff --git a/migrate_db.py b/migrate_db.py index 276a842..5e789bb 100644 --- a/migrate_db.py +++ b/migrate_db.py @@ -14,9 +14,9 @@ from datetime import datetime try: from app import app, db 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, - WidgetType, UserDashboard, DashboardWidget, WidgetTemplate) + WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility) from werkzeug.security import generate_password_hash FLASK_AVAILABLE = True except ImportError: @@ -74,6 +74,7 @@ def run_all_migrations(db_path=None): migrate_task_system(db_path) migrate_system_events(db_path) migrate_dashboard_system(db_path) + migrate_comment_system(db_path) # Run PostgreSQL-specific migrations if applicable if FLASK_AVAILABLE: @@ -174,7 +175,8 @@ def run_basic_migrations(db_path): ('business_name', "ALTER TABLE user ADD COLUMN business_name VARCHAR(100)"), ('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_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: @@ -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): """Migrate to company-based multi-tenancy model.""" @@ -460,6 +484,7 @@ def migrate_user_roles(cursor): business_name VARCHAR(100), two_factor_enabled BOOLEAN DEFAULT 0, two_factor_secret VARCHAR(32), + avatar_url VARCHAR(255), FOREIGN KEY (company_id) REFERENCES company (id), FOREIGN KEY (team_id) REFERENCES team (id) ) @@ -485,7 +510,7 @@ def migrate_user_roles(cursor): WHEN account_type IN (?, ?) THEN account_type ELSE ? END as account_type, - business_name, two_factor_enabled, two_factor_secret + business_name, two_factor_enabled, two_factor_secret, avatar_url FROM user """, (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, @@ -969,6 +994,7 @@ def create_all_tables(cursor): business_name VARCHAR(100), two_factor_enabled BOOLEAN DEFAULT 0, two_factor_secret VARCHAR(32), + avatar_url VARCHAR(255), FOREIGN KEY (company_id) REFERENCES company (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.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!") except Exception as e: @@ -1270,6 +1379,64 @@ def migrate_dashboard_system(db_file=None): 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(): """Main function with command line interface.""" parser = argparse.ArgumentParser(description='TimeTrack Database Migration Tool') diff --git a/models.py b/models.py index 19f64bb..fab0828 100644 --- a/models.py +++ b/models.py @@ -161,6 +161,9 @@ class User(db.Model): # Two-Factor Authentication fields two_factor_enabled = db.Column(db.Boolean, default=False) two_factor_secret = db.Column(db.String(32), nullable=True) # Base32 encoded secret + + # Avatar field + avatar_url = db.Column(db.String(255), nullable=True) # URL to user's avatar image # Relationships time_entries = db.relationship('TimeEntry', backref='user', lazy=True) @@ -215,6 +218,42 @@ class User(db.Model): totp = pyotp.TOTP(self.two_factor_secret) return totp.verify(token, valid_window=1) # Allow 1 window tolerance + def get_avatar_url(self, size=40): + """Get user's avatar URL or generate a default one""" + if self.avatar_url: + return self.avatar_url + + # Generate a default avatar using DiceBear Avatars (similar to GitHub's identicons) + # Using initials style for a clean, professional look + import hashlib + + # Create a hash from username for consistent colors + hash_input = f"{self.username}_{self.id}".encode('utf-8') + hash_hex = hashlib.md5(hash_input).hexdigest() + + # Use DiceBear API for avatar generation + # For initials style, we need to provide the actual initials + initials = self.get_initials() + + # Generate avatar URL with initials + # Using a color based on the hash for consistency + bg_colors = ['0ea5e9', '8b5cf6', 'ec4899', 'f59e0b', '10b981', 'ef4444', '3b82f6', '6366f1'] + color_index = int(hash_hex[:2], 16) % len(bg_colors) + bg_color = bg_colors[color_index] + + avatar_url = f"https://api.dicebear.com/7.x/initials/svg?seed={initials}&size={size}&backgroundColor={bg_color}&fontSize=50" + + return avatar_url + + def get_initials(self): + """Get user initials for avatar display""" + parts = self.username.split() + if len(parts) >= 2: + return f"{parts[0][0]}{parts[-1][0]}".upper() + elif self.username: + return self.username[:2].upper() + return "??" + def __repr__(self): return f'' @@ -362,6 +401,42 @@ class CompanyWorkConfig(db.Model): } 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'' + # User Preferences (User-configurable display settings) class UserPreferences(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -597,6 +672,72 @@ class SubTask(db.Model): """Check if a user can access this subtask""" 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'' + + 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 class Announcement(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/static/css/style.css b/static/css/style.css index 9f94f8b..d9df640 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -2396,7 +2396,8 @@ input[type="time"]::-webkit-datetime-edit { /* User Dropdown Styles */ .user-dropdown-toggle { - display: block; + display: flex; + align-items: center; padding: 0.5rem 1.25rem; color: #495057; text-decoration: none; @@ -2412,9 +2413,7 @@ input[type="time"]::-webkit-datetime-edit { color: #333; } -.user-dropdown-toggle .nav-icon { - margin-right: 1rem; -} +/* Removed nav-icon style as we're using avatar instead */ .sidebar.collapsed .user-dropdown-toggle .nav-text { display: none; @@ -2464,6 +2463,7 @@ input[type="time"]::-webkit-datetime-edit { color: #333; border-radius: 4px 4px 0 0; border-bottom: 1px solid #e0e0e0; + text-align: center; } .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-container { margin-top: 0.5rem; diff --git a/static/uploads/avatars/.gitkeep b/static/uploads/avatars/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/layout.html b/templates/layout.html index 3776599..98c0aa8 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -40,13 +40,14 @@ {% if g.user %} - 👤 + {{ g.user.username }} {{ g.user.username }}
+ {{ g.user.username }}

{{ g.user.username }}