diff --git a/app.py b/app.py index d4584d7..74bfabc 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 -from models import db, TimeEntry, WorkConfig, User, SystemSettings +from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role import logging from datetime import datetime, time, timedelta import os @@ -78,6 +78,38 @@ def admin_required(f): return f(*args, **kwargs) return decorated_function +# Add this decorator function after your existing decorators +def role_required(min_role): + """ + Decorator to restrict access based on user role. + min_role should be a Role enum value (e.g., Role.TEAM_LEADER) + """ + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if g.user is None: + return redirect(url_for('login', next=request.url)) + + # Admin always has access + if g.user.is_admin: + return f(*args, **kwargs) + + # Check role hierarchy + role_hierarchy = { + Role.TEAM_MEMBER: 1, + Role.TEAM_LEADER: 2, + Role.SUPERVISOR: 3, + Role.ADMIN: 4 + } + + if role_hierarchy.get(g.user.role, 0) < role_hierarchy.get(min_role, 0): + flash('You do not have sufficient permissions to access this page.', 'error') + return redirect(url_for('home')) + + return f(*args, **kwargs) + return decorator + return decorator + @app.before_request def load_logged_in_user(): user_id = session.get('user_id') @@ -114,21 +146,44 @@ def login(): user = User.query.filter_by(username=username).first() - if user and user.check_password(password): # Use the check_password method - # Check if user is blocked - if user.is_blocked: - flash('Your account has been disabled. Please contact an administrator.', 'error') - return render_template('login.html') + if user: + # Fix role if it's a string or None + if isinstance(user.role, str) or user.role is None: + # Map string role values to enum values + role_mapping = { + 'Team Member': Role.TEAM_MEMBER, + 'TEAM_MEMBER': Role.TEAM_MEMBER, + 'Team Leader': Role.TEAM_LEADER, + 'TEAM_LEADER': Role.TEAM_LEADER, + 'Supervisor': Role.SUPERVISOR, + 'SUPERVISOR': Role.SUPERVISOR, + 'Administrator': Role.ADMIN, + 'ADMIN': Role.ADMIN + } - # Continue with normal login process - session['user_id'] = user.id - session['username'] = user.username - session['is_admin'] = user.is_admin + if isinstance(user.role, str): + user.role = role_mapping.get(user.role, Role.TEAM_MEMBER) + else: + user.role = Role.ADMIN if user.is_admin else Role.TEAM_MEMBER + + db.session.commit() - flash('Login successful!', 'success') - return redirect(url_for('home')) - else: - flash('Invalid username or password', 'error') + # Now proceed with password check + if user.check_password(password): + # Check if user is blocked + if user.is_blocked: + flash('Your account has been disabled. Please contact an administrator.', 'error') + return render_template('login.html') + + # Continue with normal login process + session['user_id'] = user.id + session['username'] = user.username + session['is_admin'] = user.is_admin + + flash('Login successful!', 'success') + return redirect(url_for('home')) + + flash('Invalid username or password', 'error') return render_template('login.html', title='Login') @@ -245,7 +300,11 @@ def create_user(): email = request.form.get('email') password = request.form.get('password') is_admin = 'is_admin' in request.form - auto_verify = 'auto_verify' in request.form # New checkbox for auto verification + auto_verify = 'auto_verify' in request.form + + # Get role and team + role_name = request.form.get('role') + team_id = request.form.get('team_id') # Validate input error = None @@ -261,7 +320,21 @@ def create_user(): error = 'Email already registered' if error is None: - new_user = User(username=username, email=email, is_admin=is_admin, is_verified=auto_verify) + # Convert role string to enum + try: + role = Role[role_name] if role_name else Role.TEAM_MEMBER + except KeyError: + role = Role.TEAM_MEMBER + + # Create new user with role and team + new_user = User( + username=username, + email=email, + is_admin=is_admin, + is_verified=auto_verify, + role=role, + team_id=team_id if team_id else None + ) new_user.set_password(password) if not auto_verify: @@ -293,7 +366,11 @@ The TimeTrack Team flash(error, 'error') - return render_template('create_user.html', title='Create User') + # Get all teams for the form + teams = Team.query.all() + roles = [role for role in Role] + + return render_template('create_user.html', title='Create User', teams=teams, roles=roles) @app.route('/admin/users/edit/', methods=['GET', 'POST']) @admin_required @@ -306,6 +383,10 @@ def edit_user(user_id): password = request.form.get('password') is_admin = 'is_admin' in request.form + # Get role and team + role_name = request.form.get('role') + team_id = request.form.get('team_id') + # Validate input error = None if not username: @@ -322,6 +403,14 @@ def edit_user(user_id): user.email = email user.is_admin = is_admin + # Convert role string to enum + try: + user.role = Role[role_name] if role_name else Role.TEAM_MEMBER + except KeyError: + user.role = Role.TEAM_MEMBER + + user.team_id = team_id if team_id else None + if password: user.set_password(password) @@ -332,7 +421,11 @@ def edit_user(user_id): flash(error, 'error') - return render_template('edit_user.html', title='Edit User', user=user) + # Get all teams for the form + teams = Team.query.all() + roles = [role for role in Role] + + return render_template('edit_user.html', title='Edit User', user=user, teams=teams, roles=roles) @app.route('/admin/users/delete/', methods=['POST']) @admin_required @@ -728,5 +821,155 @@ def admin_settings(): return render_template('admin_settings.html', title='System Settings', settings=settings) +# Add these routes for team management +@app.route('/admin/teams') +@admin_required +def admin_teams(): + teams = Team.query.all() + return render_template('admin_teams.html', title='Team Management', teams=teams) + +@app.route('/admin/teams/create', methods=['GET', 'POST']) +@admin_required +def create_team(): + if request.method == 'POST': + name = request.form.get('name') + description = request.form.get('description') + + # Validate input + error = None + if not name: + error = 'Team name is required' + elif Team.query.filter_by(name=name).first(): + error = 'Team name already exists' + + if error is None: + new_team = Team(name=name, description=description) + db.session.add(new_team) + db.session.commit() + + flash(f'Team "{name}" created successfully!', 'success') + return redirect(url_for('admin_teams')) + + flash(error, 'error') + + return render_template('create_team.html', title='Create Team') + +@app.route('/admin/teams/edit/', methods=['GET', 'POST']) +@admin_required +def edit_team(team_id): + team = Team.query.get_or_404(team_id) + + if request.method == 'POST': + name = request.form.get('name') + description = request.form.get('description') + + # Validate input + error = None + if not name: + error = 'Team name is required' + elif name != team.name and Team.query.filter_by(name=name).first(): + error = 'Team name already exists' + + if error is None: + team.name = name + team.description = description + db.session.commit() + + flash(f'Team "{name}" updated successfully!', 'success') + return redirect(url_for('admin_teams')) + + flash(error, 'error') + + return render_template('edit_team.html', title='Edit Team', team=team) + +@app.route('/admin/teams/delete/', methods=['POST']) +@admin_required +def delete_team(team_id): + team = Team.query.get_or_404(team_id) + + # Check if team has members + if team.users: + flash('Cannot delete team with members. Remove all members first.', 'error') + return redirect(url_for('admin_teams')) + + team_name = team.name + db.session.delete(team) + db.session.commit() + + flash(f'Team "{team_name}" deleted successfully!', 'success') + return redirect(url_for('admin_teams')) + +@app.route('/admin/teams/', methods=['GET', 'POST']) +@admin_required +def manage_team(team_id): + team = Team.query.get_or_404(team_id) + + if request.method == 'POST': + action = request.form.get('action') + + if action == 'update_team': + # Update team details + name = request.form.get('name') + description = request.form.get('description') + + # Validate input + error = None + if not name: + error = 'Team name is required' + elif name != team.name and Team.query.filter_by(name=name).first(): + error = 'Team name already exists' + + if error is None: + team.name = name + team.description = description + db.session.commit() + flash(f'Team "{name}" updated successfully!', 'success') + else: + flash(error, 'error') + + elif action == 'add_member': + # Add user to team + user_id = request.form.get('user_id') + if user_id: + user = User.query.get(user_id) + if user: + user.team_id = team.id + db.session.commit() + flash(f'User {user.username} added to team!', 'success') + else: + flash('User not found', 'error') + else: + flash('No user selected', 'error') + + elif action == 'remove_member': + # Remove user from team + user_id = request.form.get('user_id') + if user_id: + user = User.query.get(user_id) + if user and user.team_id == team.id: + user.team_id = None + db.session.commit() + flash(f'User {user.username} removed from team!', 'success') + else: + flash('User not found or not in this team', 'error') + else: + flash('No user selected', 'error') + + # Get team members + team_members = User.query.filter_by(team_id=team.id).all() + + # Get users not in this team for the add member form + available_users = User.query.filter( + (User.team_id != team.id) | (User.team_id == None) + ).all() + + return render_template( + 'manage_team.html', + title=f'Manage Team: {team.name}', + team=team, + team_members=team_members, + available_users=available_users + ) + if __name__ == '__main__': app.run(debug=True) \ No newline at end of file diff --git a/migrate_roles_teams.py b/migrate_roles_teams.py new file mode 100644 index 0000000..7556b3a --- /dev/null +++ b/migrate_roles_teams.py @@ -0,0 +1,89 @@ +from app import app, db +from models import User, Team, Role, SystemSettings +from sqlalchemy import text +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def migrate_roles_teams(): + with app.app_context(): + logger.info("Starting migration for roles and teams...") + + # Check if the team table exists + try: + # Create the team table if it doesn't exist + db.engine.execute(text(""" + CREATE TABLE IF NOT EXISTS team ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL UNIQUE, + description VARCHAR(255), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """)) + logger.info("Team table created or already exists") + except Exception as e: + logger.error(f"Error creating team table: {e}") + return + + # Check if the user table has the role and team_id columns + try: + # Check if role column exists + result = db.engine.execute(text("PRAGMA table_info(user)")) + columns = [row[1] for row in result] + + if 'role' not in columns: + # Use the enum name instead of the value + db.engine.execute(text("ALTER TABLE user ADD COLUMN role VARCHAR(20) DEFAULT 'TEAM_MEMBER'")) + logger.info("Added role column to user table") + + if 'team_id' not in columns: + db.engine.execute(text("ALTER TABLE user ADD COLUMN team_id INTEGER REFERENCES team(id)")) + logger.info("Added team_id column to user table") + + # Create a default team for existing users + default_team = Team.query.filter_by(name="Default Team").first() + if not default_team: + default_team = Team(name="Default Team", description="Default team for existing users") + db.session.add(default_team) + db.session.commit() + logger.info("Created default team") + + # Map string role values to enum values + role_mapping = { + 'Team Member': Role.TEAM_MEMBER, + 'TEAM_MEMBER': Role.TEAM_MEMBER, + 'Team Leader': Role.TEAM_LEADER, + 'TEAM_LEADER': Role.TEAM_LEADER, + 'Supervisor': Role.SUPERVISOR, + 'SUPERVISOR': Role.SUPERVISOR, + 'Administrator': Role.ADMIN, + 'admin': Role.ADMIN, + 'ADMIN': Role.ADMIN + } + + # Assign all existing users to the default team and set role based on admin status + users = User.query.all() + for user in users: + if user.team_id is None: + user.team_id = default_team.id + + # Handle role conversion properly + if isinstance(user.role, str): + # Try to map the string to an enum value + user.role = role_mapping.get(user.role, Role.TEAM_MEMBER) + elif user.role is None: + # Set default role based on admin status + user.role = Role.ADMIN if user.is_admin else Role.TEAM_MEMBER + + db.session.commit() + logger.info(f"Assigned {len(users)} existing users to default team and updated roles") + + except Exception as e: + logger.error(f"Error updating user table: {e}") + return + + logger.info("Migration completed successfully") + +if __name__ == "__main__": + migrate_roles_teams() \ No newline at end of file diff --git a/models.py b/models.py index 2db9d7d..8ed7ba5 100644 --- a/models.py +++ b/models.py @@ -2,9 +2,31 @@ from flask_sqlalchemy import SQLAlchemy from werkzeug.security import generate_password_hash, check_password_hash from datetime import datetime, timedelta import secrets +import enum db = SQLAlchemy() +# Define Role as an Enum for better type safety +class Role(enum.Enum): + TEAM_MEMBER = "Team Member" + TEAM_LEADER = "Team Leader" + SUPERVISOR = "Supervisor" + ADMIN = "Administrator" # Keep existing admin role + +# Create Team model +class Team(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False, unique=True) + description = db.Column(db.String(255)) + created_at = db.Column(db.DateTime, default=datetime.now) + + # Relationship with users (one team has many users) + users = db.relationship('User', backref='team', lazy=True) + + def __repr__(self): + return f'' + +# Update User model to include role and team relationship class User(db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) @@ -18,13 +40,17 @@ class User(db.Model): verification_token = db.Column(db.String(100), unique=True, nullable=True) token_expiry = db.Column(db.DateTime, nullable=True) + # New field for blocking users + is_blocked = db.Column(db.Boolean, default=False) + + # New fields for role and team + role = db.Column(db.Enum(Role), default=Role.TEAM_MEMBER) + team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True) + # Relationships time_entries = db.relationship('TimeEntry', backref='user', lazy=True) work_config = db.relationship('WorkConfig', backref='user', lazy=True, uselist=False) - # New field for blocking users - is_blocked = db.Column(db.Boolean, default=False) - def set_password(self, password): self.password_hash = generate_password_hash(password) diff --git a/repair_roles.py b/repair_roles.py new file mode 100644 index 0000000..01d229a --- /dev/null +++ b/repair_roles.py @@ -0,0 +1,47 @@ +from app import app, db +from models import User, Role +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def repair_user_roles(): + with app.app_context(): + logger.info("Starting user role repair...") + + # Map string role values to enum values + role_mapping = { + 'Team Member': Role.TEAM_MEMBER, + 'TEAM_MEMBER': Role.TEAM_MEMBER, + 'Team Leader': Role.TEAM_LEADER, + 'TEAM_LEADER': Role.TEAM_LEADER, + 'Supervisor': Role.SUPERVISOR, + 'SUPERVISOR': Role.SUPERVISOR, + 'Administrator': Role.ADMIN, + 'ADMIN': Role.ADMIN + } + + users = User.query.all() + fixed_count = 0 + + for user in users: + original_role = user.role + + # Fix role if it's a string or None + if isinstance(user.role, str): + user.role = role_mapping.get(user.role, Role.TEAM_MEMBER) + fixed_count += 1 + elif user.role is None: + user.role = Role.ADMIN if user.is_admin else Role.TEAM_MEMBER + fixed_count += 1 + + if fixed_count > 0: + db.session.commit() + logger.info(f"Fixed roles for {fixed_count} users") + else: + logger.info("No role fixes needed") + + logger.info("Role repair completed") + +if __name__ == "__main__": + repair_user_roles() \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index fd6d830..4333e89 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -154,7 +154,7 @@ button { } .btn { - padding: 8px 16px; + padding: 5px 10px; border: none; border-radius: 4px; cursor: pointer; @@ -162,6 +162,27 @@ button { color: white; } +.btn-primary { + background-color: #45a049; + color: white; + margin-bottom: 1rem; + margin-right: 1rem; + margin-left: 1rem; + display: inline-block; + font-size: medium; +} + +.btn-sm { + padding: 5px 10px; + border-radius: 4px; + font-size: small; +} + +.btn-secondary { + background-color: #f44336; + color: white; +} + .btn:hover { background-color: #45a049; } @@ -352,20 +373,6 @@ footer { font-style: italic; } -.btn-primary { - background-color: #007bff; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - font-size: 1rem; -} - -.btn-primary:hover { - background-color: #0069d9; -} - .alert { padding: 0.75rem 1rem; margin-bottom: 1rem; @@ -474,6 +481,14 @@ input[type="time"]::-webkit-datetime-edit { } /* Admin Dashboard Styles */ +.admin-container { + padding: 1.5rem; + background-color: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + margin-bottom: 2rem; +} + .admin-panel { display: flex; flex-wrap: wrap; @@ -596,4 +611,26 @@ input[type="time"]::-webkit-datetime-edit { .form-actions { margin-top: 20px; +} + +/* General table styling */ +.data-table { + width: 100%; + border-collapse: collapse; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.data-table th, .data-table td { + padding: 0.8rem; + text-align: left; + border-bottom: 1px solid #ddd; +} + +.data-table th { + background-color: #f2f2f2; + font-weight: bold; +} + +.data-table tr:hover { + background-color: #f5f5f5; } \ No newline at end of file diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index d431a5e..645f5a3 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -11,6 +11,12 @@ Manage Users +
+

Team Management

+

Configure teams and their members.

+ Configure +
+

System Settings

Configure application-wide settings like registration and more.

diff --git a/templates/admin_teams.html b/templates/admin_teams.html new file mode 100644 index 0000000..37c289f --- /dev/null +++ b/templates/admin_teams.html @@ -0,0 +1,44 @@ +{% extends 'layout.html' %} + +{% block content %} +
+

Team Management

+ + + + {% if teams %} + + + + + + + + + + + + {% for team in teams %} + + + + + + + + {% endfor %} + +
NameDescriptionMembersCreatedActions
{{ team.name }}{{ team.description }}{{ team.users|length }}{{ team.created_at.strftime('%Y-%m-%d') }} + Manage +
+ +
+
+ + {% else %} +

No teams found. Create a team to get started.

+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/admin_users.html b/templates/admin_users.html index e06aa3d..63ed340 100644 --- a/templates/admin_users.html +++ b/templates/admin_users.html @@ -9,7 +9,7 @@
- +
diff --git a/templates/create_team.html b/templates/create_team.html new file mode 100644 index 0000000..490efef --- /dev/null +++ b/templates/create_team.html @@ -0,0 +1,24 @@ +{% extends "layout.html" %} + +{% block content %} +
+

Create New Team

+ +
+
+ + +
+ +
+ + +
+ +
+ + Cancel +
+ +
+{% endblock %} \ No newline at end of file diff --git a/templates/edit_user.html b/templates/edit_user.html index da7f2c7..f031d30 100644 --- a/templates/edit_user.html +++ b/templates/edit_user.html @@ -20,6 +20,29 @@ +
+ + +
+ +
+ + +
+
Username
+ + + + + + + + + + {% for member in team_members %} + + + + + + + {% endfor %} + +
UsernameEmailRoleActions
{{ member.username }}{{ member.email }}{{ member.role.value }} +
+ + + +
+
+ {% else %} +

No members in this team yet.

+ {% endif %} +
+ + +
+
+

Add Team Member

+
+
+ {% if available_users %} +
+ +
+ + +
+ +
+ {% else %} +

No available users to add to this team.

+ {% endif %} +
+
+ + + +{% endblock %} \ No newline at end of file