diff --git a/app.py b/app.py index 893cfa1..ddd54a7 100644 --- a/app.py +++ b/app.py @@ -212,7 +212,7 @@ STAFF_LEVEL_ROLES = ['staff', 'payroll', 'project_manager'] # Import and initialize models from models import set_db -User, QRCode, QRCodeStyle, Project, AttendanceData, Employee, TimeAttendance = set_db(db) +User, QRCode, QRCodeStyle, Project, AttendanceData, Employee, TimeAttendance, UserProjectPermission, UserLocationPermission = set_db(db) # Initialize the logging system logger_handler = AppLogger(app, db) @@ -1816,28 +1816,42 @@ def users(): @admin_required @log_database_operations('user_creation') def create_user(): - """Create new user (Admin only)""" + """Create new user (Admin only) with Project Manager permissions support""" if request.method == 'POST': try: - full_name = request.form['full_name'] - email = request.form['email'] - username = request.form['username'] - password = request.form['password'] - role = request.form['role'] + # Get basic form data + full_name = request.form.get('full_name', '').strip() + email = request.form.get('email', '').strip() + username = request.form.get('username', '').strip() + password = request.form.get('password', '') + role = request.form.get('role', '') + + # Validate required fields + if not all([full_name, email, username, password, role]): + flash('All fields are required.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + return render_template('create_user.html', projects=projects, locations=locations) # Validate role if role not in VALID_ROLES: flash(f'Invalid role selected. Valid roles: {", ".join(VALID_ROLES)}', 'error') - return render_template('create_user.html') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + return render_template('create_user.html', projects=projects, locations=locations) # Check if user already exists if User.query.filter_by(username=username).first(): flash('Username already exists.', 'error') - return render_template('create_user.html') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + return render_template('create_user.html', projects=projects, locations=locations) if User.query.filter_by(email=email).first(): flash('Email already registered.', 'error') - return render_template('create_user.html') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + return render_template('create_user.html', projects=projects, locations=locations) # Create new user new_user = User( @@ -1850,6 +1864,69 @@ def create_user(): new_user.set_password(password) db.session.add(new_user) + db.session.flush() # Get the user ID without committing + + # Handle Project Manager permissions + if role == 'project_manager': + # Get selected projects - getlist returns empty list if field doesn't exist + selected_projects = request.form.getlist('assigned_projects') + + # Validate and filter project IDs + valid_project_ids = [] + if selected_projects: + for pid in selected_projects: + try: + project_id = int(pid) + # Verify project exists + if Project.query.get(project_id): + valid_project_ids.append(project_id) + except (ValueError, TypeError): + logger_handler.logger.warning(f"Invalid project ID received: {pid}") + + # Add project permissions + if valid_project_ids: + for project_id in valid_project_ids: + try: + permission = UserProjectPermission( + user_id=new_user.id, + project_id=project_id + ) + db.session.add(permission) + except Exception as e: + logger_handler.logger.error(f"Error adding project permission: {e}") + + logger_handler.logger.info( + f"Admin {session['username']} assigned {len(valid_project_ids)} projects to new Project Manager {username}" + ) + + # Get selected locations + selected_locations = request.form.getlist('assigned_locations') + + # Filter and clean location names + valid_locations = [] + if selected_locations: + for location in selected_locations: + location_clean = location.strip() + if location_clean: + valid_locations.append(location_clean) + + # Add location permissions + if valid_locations: + for location_name in valid_locations: + try: + permission = UserLocationPermission( + user_id=new_user.id, + location_name=location_name + ) + db.session.add(permission) + except Exception as e: + logger_handler.logger.error(f"Error adding location permission: {e}") + + logger_handler.logger.info( + f"Admin {session['username']} assigned {len(valid_locations)} locations to new Project Manager {username}" + ) + + # Commit all changes db.session.commit() # Log user creation @@ -1858,13 +1935,47 @@ def create_user(): flash(f'User "{full_name}" created successfully with role "{role}".', 'success') return redirect(url_for('users')) + except KeyError as e: + db.session.rollback() + logger_handler.logger.error(f"Missing form field: {e}") + flash(f'Missing required field: {e}. Please fill in all fields.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + return render_template('create_user.html', projects=projects, locations=locations) except Exception as e: db.session.rollback() logger_handler.log_database_error('user_creation', e) - flash('Failed to create user. Please try again.', 'error') + logger_handler.logger.error(f"User creation error details: {str(e)}") + flash('User creation failed. Please try again.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + return render_template('create_user.html', projects=projects, locations=locations) - return render_template('create_user.html', valid_roles=VALID_ROLES) + # GET request - load form with projects and locations + try: + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + return render_template('create_user.html', projects=projects, locations=locations) + except Exception as e: + logger_handler.logger.error(f"Error loading create user form: {e}") + flash('Error loading form. Please try again.', 'error') + return redirect(url_for('users')) +def get_all_locations_from_qr_codes(): + """Helper function to get all unique locations from QR codes""" + try: + result = db.session.execute(text(""" + SELECT DISTINCT location + FROM qr_codes + WHERE location IS NOT NULL + AND active_status = 1 + ORDER BY location + """)) + return [row[0] for row in result.fetchall()] + except Exception as e: + logger_handler.logger.error(f"Error loading locations: {e}") + return [] + @app.route('/users//delete', methods=['GET', 'POST']) @admin_required def delete_user(user_id): @@ -2001,35 +2112,72 @@ def demote_user(user_id): @app.route('/users//edit', methods=['GET', 'POST']) @admin_required -@log_database_operations('user_update') +@log_database_operations('user_edit') def edit_user(user_id): - """Edit user details (Admin only)""" + """Edit existing user with Project Manager permissions support""" try: user_to_edit = User.query.get_or_404(user_id) + + # Track old role for permission cleanup + old_role = user_to_edit.role if request.method == 'POST': - # Track changes - changes = {} + # Store old values for change tracking old_values = { 'full_name': user_to_edit.full_name, 'email': user_to_edit.email, - 'role': user_to_edit.role + 'username': user_to_edit.username, + 'role': user_to_edit.role, + 'active_status': user_to_edit.active_status } + changes = {} - # Update user details - user_to_edit.full_name = request.form['full_name'] - user_to_edit.email = request.form['email'] - new_role = request.form['role'] + # Update basic info with validation + full_name = request.form.get('full_name', '').strip() + email = request.form.get('email', '').strip() + username = request.form.get('username', '').strip() + + if not all([full_name, email, username]): + flash('Name, email, and username are required.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + assigned_project_ids = [] + assigned_location_names = [] + if user_to_edit.role == 'project_manager': + assigned_project_ids = [p.project_id for p in UserProjectPermission.query.filter_by(user_id=user_id).all()] + assigned_location_names = [l.location_name for l in UserLocationPermission.query.filter_by(user_id=user_id).all()] + return render_template('edit_user.html', user=user_to_edit, valid_roles=VALID_ROLES, + projects=projects, locations=locations, + assigned_project_ids=assigned_project_ids, + assigned_location_names=assigned_location_names) - # Validate role + user_to_edit.full_name = full_name + user_to_edit.email = email + user_to_edit.username = username + + # Update active status + user_to_edit.active_status = 'active_status' in request.form + + # Update role with validation + new_role = request.form.get('role', '') if new_role not in VALID_ROLES: flash(f'Invalid role selected. Valid roles: {", ".join(VALID_ROLES)}', 'error') - return render_template('edit_user.html', user=user_to_edit, valid_roles=VALID_ROLES) + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + assigned_project_ids = [] + assigned_location_names = [] + if user_to_edit.role == 'project_manager': + assigned_project_ids = [p.project_id for p in UserProjectPermission.query.filter_by(user_id=user_id).all()] + assigned_location_names = [l.location_name for l in UserLocationPermission.query.filter_by(user_id=user_id).all()] + return render_template('edit_user.html', user=user_to_edit, valid_roles=VALID_ROLES, + projects=projects, locations=locations, + assigned_project_ids=assigned_project_ids, + assigned_location_names=assigned_location_names) user_to_edit.role = new_role # Handle password update if provided - new_password = request.form.get('new_password') + new_password = request.form.get('new_password', '') if new_password and new_password.strip(): user_to_edit.set_password(new_password) changes['password'] = 'Password updated' @@ -2040,25 +2188,141 @@ def edit_user(user_id): severity="MEDIUM" ) + # Handle Project Manager permissions + if new_role == 'project_manager': + # Update project permissions + # First, remove existing project permissions + try: + UserProjectPermission.query.filter_by(user_id=user_id).delete() + except Exception as e: + logger_handler.logger.error(f"Error deleting old project permissions: {e}") + + # Add new project permissions + selected_projects = request.form.getlist('assigned_projects') + + # Validate project IDs + valid_project_ids = [] + if selected_projects: + for pid in selected_projects: + try: + project_id = int(pid) + # Verify project exists + if Project.query.get(project_id): + valid_project_ids.append(project_id) + except (ValueError, TypeError): + logger_handler.logger.warning(f"Invalid project ID received: {pid}") + + # Add validated project permissions + if valid_project_ids: + for project_id in valid_project_ids: + try: + permission = UserProjectPermission( + user_id=user_id, + project_id=project_id + ) + db.session.add(permission) + except Exception as e: + logger_handler.logger.error(f"Error adding project permission: {e}") + + changes['assigned_projects'] = f'{len(valid_project_ids)} projects assigned' + logger_handler.logger.info( + f"Admin {session['username']} updated project permissions for Project Manager {user_to_edit.username}: {len(valid_project_ids)} projects" + ) + + # Update location permissions + # First, remove existing location permissions + try: + UserLocationPermission.query.filter_by(user_id=user_id).delete() + except Exception as e: + logger_handler.logger.error(f"Error deleting old location permissions: {e}") + + # Add new location permissions + selected_locations = request.form.getlist('assigned_locations') + + # Validate and clean locations + valid_locations = [] + if selected_locations: + for location in selected_locations: + location_clean = location.strip() + if location_clean: + valid_locations.append(location_clean) + + # Add validated location permissions + if valid_locations: + for location_name in valid_locations: + try: + permission = UserLocationPermission( + user_id=user_id, + location_name=location_name + ) + db.session.add(permission) + except Exception as e: + logger_handler.logger.error(f"Error adding location permission: {e}") + + changes['assigned_locations'] = f'{len(valid_locations)} locations assigned' + logger_handler.logger.info( + f"Admin {session['username']} updated location permissions for Project Manager {user_to_edit.username}: {len(valid_locations)} locations" + ) + + # If role changed from project_manager to something else, remove permissions + elif old_role == 'project_manager' and new_role != 'project_manager': + try: + UserProjectPermission.query.filter_by(user_id=user_id).delete() + UserLocationPermission.query.filter_by(user_id=user_id).delete() + logger_handler.logger.info( + f"Admin {session['username']} removed Project Manager permissions from user {user_to_edit.username} (role changed to {new_role})" + ) + except Exception as e: + logger_handler.logger.error(f"Error removing permissions: {e}") + # Track changes for field, old_value in old_values.items(): new_value = getattr(user_to_edit, field) if old_value != new_value: changes[field] = {'old': old_value, 'new': new_value} + # Commit all changes db.session.commit() # Log user update if changes: - logger_handler.logger.info(f"Admin user {session['username']} updated user {user_to_edit.username}: {json.dumps(changes)}") + logger_handler.logger.info(f"Admin user {session['username']} updated user {user_to_edit.username}: {json.dumps(changes, default=str)}") flash(f'User "{user_to_edit.full_name}" updated successfully.', 'success') return redirect(url_for('users')) - return render_template('edit_user.html', user=user_to_edit, valid_roles=VALID_ROLES) + # GET request - load form with current assignments + try: + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + + # Get current assignments if user is a project manager + assigned_project_ids = [] + assigned_location_names = [] + + if user_to_edit.role == 'project_manager': + try: + assigned_project_ids = [p.project_id for p in UserProjectPermission.query.filter_by(user_id=user_id).all()] + assigned_location_names = [l.location_name for l in UserLocationPermission.query.filter_by(user_id=user_id).all()] + except Exception as e: + logger_handler.logger.error(f"Error loading current permissions: {e}") + + return render_template('edit_user.html', + user=user_to_edit, + valid_roles=VALID_ROLES, + projects=projects, + locations=locations, + assigned_project_ids=assigned_project_ids, + assigned_location_names=assigned_location_names) + except Exception as e: + logger_handler.logger.error(f"Error loading edit user form: {e}") + flash('Error loading edit form. Please try again.', 'error') + return redirect(url_for('users')) except Exception as e: + db.session.rollback() logger_handler.log_database_error('user_update', e) + logger_handler.logger.error(f"User update error details: {str(e)}") flash('Error updating user. Please try again.', 'error') return redirect(url_for('users')) @@ -2240,6 +2504,47 @@ def user_stats_api(): logger_handler.log_database_error('user_stats_api', e) print(f"Error fetching user stats: {e}") return jsonify({'error': 'Failed to fetch user statistics'}), 500 + +@app.route('/api/locations-by-projects', methods=['POST']) +@admin_required +def get_locations_by_projects(): + """Get locations that belong to selected projects""" + try: + data = request.get_json() + project_ids = data.get('project_ids', []) + + if not project_ids: + # No projects selected, return empty list + return jsonify({ + 'success': True, + 'locations': [], + 'message': 'No projects selected' + }) + + # Get unique locations from QR codes that belong to selected projects + result = db.session.execute(text(""" + SELECT DISTINCT location + FROM qr_codes + WHERE project_id IN :project_ids + AND location IS NOT NULL + AND active_status = 1 + ORDER BY location + """), {'project_ids': tuple(project_ids)}) + + locations = [row[0] for row in result.fetchall()] + + return jsonify({ + 'success': True, + 'locations': locations, + 'count': len(locations) + }) + + except Exception as e: + logger_handler.logger.error(f"Error fetching locations by projects: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 @app.route('/api/roles/permissions') @admin_required diff --git a/models/__init__.py b/models/__init__.py index 02123a1..55a73ca 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -19,5 +19,6 @@ def set_db(database): from .attendance import AttendanceData from .employee import Employee from .time_attendance import TimeAttendance + from .permissions import UserProjectPermission, UserLocationPermission - return User, QRCode, QRCodeStyle, Project, AttendanceData, Employee, TimeAttendance \ No newline at end of file + return User, QRCode, QRCodeStyle, Project, AttendanceData, Employee, TimeAttendance, UserProjectPermission, UserLocationPermission \ No newline at end of file diff --git a/models/permissions.py b/models/permissions.py new file mode 100644 index 0000000..68cee1a --- /dev/null +++ b/models/permissions.py @@ -0,0 +1,48 @@ +""" +Permission Models for QR Attendance Management System +==================================================== + +Permission models to manage Project Manager access control. +These models define which projects and locations a Project Manager can access. +""" + +from datetime import datetime +from . import base + +class UserProjectPermission(base.db.Model): + """ + UserProjectPermission model to manage project access for Project Managers + Links users to specific projects they are allowed to view + """ + __tablename__ = 'user_project_permissions' + + id = base.db.Column(base.db.Integer, primary_key=True) + user_id = base.db.Column(base.db.Integer, base.db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + project_id = base.db.Column(base.db.Integer, base.db.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False) + created_date = base.db.Column(base.db.DateTime, default=datetime.utcnow) + + # Relationships + user = base.db.relationship('User', backref=base.db.backref('project_permissions', lazy='dynamic', cascade='all, delete-orphan')) + project = base.db.relationship('Project', backref=base.db.backref('user_permissions', lazy='dynamic')) + + def __repr__(self): + return f'' + + +class UserLocationPermission(base.db.Model): + """ + UserLocationPermission model to manage location access for Project Managers + Links users to specific locations they are allowed to view + """ + __tablename__ = 'user_location_permissions' + + id = base.db.Column(base.db.Integer, primary_key=True) + user_id = base.db.Column(base.db.Integer, base.db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + location_name = base.db.Column(base.db.String(200), nullable=False) + created_date = base.db.Column(base.db.DateTime, default=datetime.utcnow) + + # Relationships + user = base.db.relationship('User', backref=base.db.backref('location_permissions', lazy='dynamic', cascade='all, delete-orphan')) + + def __repr__(self): + return f'' \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index 3b86085..c8bd9c9 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -649,6 +649,74 @@ a:hover { margin-left: auto; } +/* Project Manager Permissions Section Styles */ +.permissions-section { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 20px; + margin: 20px 0; +} + +.permissions-section .section-header { + margin-bottom: 20px; +} + +.permissions-section .section-header h3 { + color: #495057; + font-size: 1.2rem; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 10px; +} + +.permissions-section .section-description { + color: #6c757d; + font-size: 0.9rem; + margin: 0; +} + +.checkbox-group { + max-height: 250px; + overflow-y: auto; + border: 1px solid #ced4da; + border-radius: 6px; + padding: 15px; + background: #ffffff; +} + +.checkbox-label { + display: flex; + align-items: center; + padding: 8px 12px; + margin: 4px 0; + cursor: pointer; + border-radius: 4px; + transition: background-color 0.2s; +} + +.checkbox-label:hover { + background-color: #f1f3f5; +} + +.checkbox-input { + margin-right: 12px; + width: 18px; + height: 18px; + cursor: pointer; +} + +.checkbox-text { + font-size: 0.95rem; + color: #495057; +} + +.checkbox-label input:checked + .checkbox-text { + font-weight: 600; + color: #0056b3; +} + /* Footer */ .footer { background-color: var(--gray-800); @@ -1233,4 +1301,199 @@ body:not(.has-sidebar) .main-content { display: flex; gap: var(--spacing-3); justify-content: flex-end; +} + +/* Step-by-step Permission Selection Styles */ +.permission-step { + background: #ffffff; + border: 2px solid #e5e7eb; + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + transition: all 0.3s ease; +} + +.permission-step:hover { + border-color: #3b82f6; + box-shadow: 0 4px 6px rgba(59, 130, 246, 0.1); +} + +.step-header { + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 20px; +} + +.step-number { + width: 40px; + height: 40px; + background: linear-gradient(135deg, #3b82f6, #2563eb); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + font-weight: bold; + flex-shrink: 0; +} + +.step-info h4 { + margin: 0; + color: #1f2937; + font-size: 1.1rem; +} + +.step-info p { + margin: 4px 0 0 0; + color: #6b7280; + font-size: 0.9rem; +} + +.step-footer { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid #e5e7eb; +} + +.selection-summary { + display: flex; + align-items: center; + gap: 8px; + padding: 10px; + background: #f9fafb; + border-radius: 6px; + font-size: 0.95rem; + color: #374151; +} + +.selection-summary i { + font-size: 1.1rem; + color: #6b7280; +} + +.selection-summary strong { + color: #1f2937; +} + +.location-loading, +.location-empty { + padding: 30px; + text-align: center; + color: #6b7280; +} + +.location-loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.location-loading i { + font-size: 2rem; + color: #3b82f6; +} + +.location-empty i { + font-size: 2.5rem; + color: #9ca3af; + margin-bottom: 10px; +} + +.location-empty p { + margin: 10px 0 5px 0; + color: #4b5563; +} + +.location-empty small { + color: #6b7280; +} + +.permissions-overview { + background: linear-gradient(135deg, #f0f9ff, #e0f2fe); + border: 2px solid #3b82f6; + border-radius: 8px; + padding: 20px; + margin-top: 20px; +} + +.overview-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 15px; + color: #1e40af; +} + +.overview-header i { + font-size: 1.5rem; +} + +.overview-header h4 { + margin: 0; + font-size: 1.1rem; +} + +.overview-content { + display: flex; + flex-direction: column; + gap: 10px; +} + +.overview-item { + display: flex; + gap: 10px; + padding: 8px; + background: white; + border-radius: 6px; + font-size: 0.95rem; +} + +.overview-item strong { + color: #1f2937; + min-width: 90px; +} + +.overview-item span { + color: #374151; + flex: 1; +} + +.project-qr-count { + margin-left: auto; + padding: 2px 8px; + background: #e0f2fe; + color: #0369a1; + border-radius: 12px; + font-size: 0.85rem; + font-weight: 500; +} + +.project-qr-count i { + margin-right: 4px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .step-header { + flex-direction: column; + align-items: flex-start; + } + + .step-number { + width: 35px; + height: 35px; + font-size: 1rem; + } + + .overview-item { + flex-direction: column; + gap: 5px; + } + + .overview-item strong { + min-width: auto; + } } \ No newline at end of file diff --git a/templates/create_user.html b/templates/create_user.html index 4f5022a..8d9177d 100644 --- a/templates/create_user.html +++ b/templates/create_user.html @@ -110,6 +110,114 @@ Code Management{% endblock %} {% block content %} > + + + + + +

@@ -186,6 +295,7 @@ const passwordStrength = document.getElementById('passwordStrength'); const resetPasswordBtn = document.getElementById('resetPassword'); + // Role change warning system roleSelect.addEventListener('change', function() { const newRole = this.value; @@ -249,6 +359,225 @@ } }); + const pmSection = document.getElementById('pmPermissionsSection'); + const projectStep = document.getElementById('projectStep'); + const locationStep = document.getElementById('locationStep'); + const permissionsOverview = document.getElementById('permissionsOverview'); + const projectCheckboxes = document.querySelectorAll('.project-checkbox'); + const projectSummary = document.getElementById('projectSummary'); + const locationSummary = document.getElementById('locationSummary'); + const locationCheckboxGroup = document.getElementById('locationCheckboxGroup'); + const locationLoading = document.getElementById('locationLoading'); + const locationEmpty = document.getElementById('locationEmpty'); + + // Show/hide Project Manager permissions based on role selection + if (roleSelect) { + roleSelect.addEventListener('change', function() { + if (this.value === 'project_manager') { + pmSection.style.display = 'block'; + setupPermissionFlow(); + } else { + pmSection.style.display = 'none'; + resetPermissionFlow(); + } + }); + } + + // Initialize permission flow if Project Manager role is already selected + if (roleSelect && roleSelect.value === 'project_manager') { + pmSection.style.display = 'block'; + setupPermissionFlow(); + } + + // Setup cascading permission flow + function setupPermissionFlow() { + // Add event listeners to project checkboxes + projectCheckboxes.forEach(checkbox => { + checkbox.addEventListener('change', handleProjectSelection); + }); + + // Pre-select assigned projects if in edit mode + {% if assigned_project_ids %} + const assignedProjectIds = {{ assigned_project_ids | tojson }}; + projectCheckboxes.forEach(cb => { + if (assignedProjectIds.includes(parseInt(cb.value))) { + cb.checked = true; + } + }); + + // Trigger initial load if projects are assigned + if (assignedProjectIds.length > 0) { + handleProjectSelection().then(() => { + // After locations are loaded, pre-select assigned locations + {% if assigned_location_names %} + setTimeout(() => { + const assignedLocations = {{ assigned_location_names | tojson }}; + document.querySelectorAll('.location-checkbox').forEach(cb => { + if (assignedLocations.includes(cb.value)) { + cb.checked = true; + } + }); + handleLocationSelection(); + }, 500); + {% endif %} + }); + } + {% endif %} + } + + // Handle project selection changes + async function handleProjectSelection() { + const selectedProjects = Array.from(projectCheckboxes) + .filter(cb => cb.checked) + .map(cb => ({ + id: cb.value, + name: cb.dataset.projectName + })); + + // Update project summary + if (selectedProjects.length > 0) { + projectSummary.innerHTML = ` + + ${selectedProjects.length} project${selectedProjects.length > 1 ? 's' : ''} selected + `; + + // Show location step and load locations + locationStep.style.display = 'block'; + await loadLocationsByProjects(selectedProjects.map(p => p.id)); + } else { + projectSummary.innerHTML = ` + + No projects selected + `; + locationStep.style.display = 'none'; + permissionsOverview.style.display = 'none'; + } + } + + // Load locations for selected projects + async function loadLocationsByProjects(projectIds) { + locationLoading.style.display = 'flex'; + locationCheckboxGroup.innerHTML = ''; + locationEmpty.style.display = 'none'; + locationSummary.innerHTML = ` + + Loading locations... + `; + + try { + const response = await fetch('/api/locations-by-projects', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ project_ids: projectIds }) + }); + + const data = await response.json(); + + locationLoading.style.display = 'none'; + + if (data.success && data.locations.length > 0) { + // Populate location checkboxes + locationCheckboxGroup.innerHTML = data.locations.map(location => ` + + `).join(''); + + // Add event listeners to location checkboxes + document.querySelectorAll('.location-checkbox').forEach(cb => { + cb.addEventListener('change', handleLocationSelection); + }); + + locationSummary.innerHTML = ` + + ${data.count} location${data.count > 1 ? 's' : ''} available + `; + } else { + locationEmpty.style.display = 'block'; + locationSummary.innerHTML = ` + + No locations available + `; + } + + updatePermissionsOverview(); + } catch (error) { + console.error('Error loading locations:', error); + locationLoading.style.display = 'none'; + locationEmpty.style.display = 'block'; + locationSummary.innerHTML = ` + + Error loading locations + `; + } + } + + // Handle location selection changes + function handleLocationSelection() { + const selectedLocations = Array.from(document.querySelectorAll('.location-checkbox:checked')); + + if (selectedLocations.length > 0) { + locationSummary.innerHTML = ` + + ${selectedLocations.length} location${selectedLocations.length > 1 ? 's' : ''} selected + `; + } else { + const totalLocations = document.querySelectorAll('.location-checkbox').length; + locationSummary.innerHTML = ` + + ${totalLocations} location${totalLocations > 1 ? 's' : ''} available + `; + } + + updatePermissionsOverview(); + } + + // Update permissions overview + function updatePermissionsOverview() { + const selectedProjects = Array.from(projectCheckboxes) + .filter(cb => cb.checked) + .map(cb => cb.dataset.projectName); + + const selectedLocations = Array.from(document.querySelectorAll('.location-checkbox:checked')) + .map(cb => cb.value); + + if (selectedProjects.length > 0 || selectedLocations.length > 0) { + permissionsOverview.style.display = 'block'; + + document.getElementById('overviewProjects').textContent = + selectedProjects.length > 0 + ? selectedProjects.join(', ') + : 'All projects'; + + document.getElementById('overviewLocations').textContent = + selectedLocations.length > 0 + ? selectedLocations.join(', ') + : 'All locations in selected projects'; + } else { + permissionsOverview.style.display = 'none'; + } + } + + // Reset permission flow + function resetPermissionFlow() { + projectCheckboxes.forEach(cb => { + cb.checked = false; + cb.removeEventListener('change', handleProjectSelection); + }); + locationStep.style.display = 'none'; + permissionsOverview.style.display = 'none'; + locationCheckboxGroup.innerHTML = ''; + } + + function hideRoleWarning() { roleWarning.classList.remove('fade-in'); setTimeout(() => roleWarning.style.display = 'none', 300); diff --git a/tools/migration_PM_permissions.py b/tools/migration_PM_permissions.py new file mode 100644 index 0000000..8b2c93b --- /dev/null +++ b/tools/migration_PM_permissions.py @@ -0,0 +1,121 @@ +""" +Database Migration Script for Project Manager Permissions (MySQL Version) +========================================================================== + +This script creates the necessary tables for Project Manager role permissions. +It adds support for assigning specific projects and locations to Project Managers. + +Tables Created: +1. user_project_permissions: Links users to projects they can access +2. user_location_permissions: Links users to locations they can access + +Run this script ONCE after backing up your database. +""" + +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import text +import os +from dotenv import load_dotenv + +load_dotenv() + +# Initialize Flask app for migration +app = Flask(__name__) +app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'mysql://user:pass@localhost/qr_management') +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +db = SQLAlchemy(app) + +def run_migration(): + """Execute the migration to add project manager permission tables""" + + with app.app_context(): + print("\n" + "="*70) + print("PROJECT MANAGER PERMISSIONS MIGRATION (MySQL)") + print("="*70 + "\n") + + try: + # Check if tables already exist + print("šŸ” Checking if migration is needed...") + + result = db.session.execute(text(""" + SELECT TABLE_NAME + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN ('user_project_permissions', 'user_location_permissions') + """)) + existing_tables = [row[0] for row in result.fetchall()] + + if len(existing_tables) == 2: + print("āœ… Migration tables already exist. No action needed.") + return True + + if 'user_project_permissions' in existing_tables: + print("āš ļø user_project_permissions table already exists, skipping...") + else: + # Create user_project_permissions table + print("\nšŸ“ Creating user_project_permissions table...") + db.session.execute(text(""" + CREATE TABLE user_project_permissions ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + project_id INT NOT NULL, + created_date DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + UNIQUE KEY unique_user_project (user_id, project_id), + INDEX idx_user_id (user_id), + INDEX idx_project_id (project_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """)) + print("āœ… user_project_permissions table created successfully") + + if 'user_location_permissions' in existing_tables: + print("āš ļø user_location_permissions table already exists, skipping...") + else: + # Create user_location_permissions table + print("\nšŸ“ Creating user_location_permissions table...") + db.session.execute(text(""" + CREATE TABLE user_location_permissions ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + location_name VARCHAR(200) NOT NULL, + created_date DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + UNIQUE KEY unique_user_location (user_id, location_name), + INDEX idx_user_id (user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """)) + print("āœ… user_location_permissions table created successfully") + + # Commit all changes + db.session.commit() + + print("\n" + "="*70) + print("āœ… MIGRATION COMPLETED SUCCESSFULLY") + print("="*70) + print("\nNext Steps:") + print("1. The tables are ready for use") + print("2. You can now assign projects and locations to Project Managers") + print("3. Restart your application\n") + + return True + + except Exception as e: + db.session.rollback() + print(f"\nāŒ Migration failed: {e}") + print("Please check your database and try again.") + return False + +if __name__ == '__main__': + print("\nāš ļø IMPORTANT: Backup your database before running this migration!") + response = input("Continue with migration? (yes/no): ") + + if response.lower() == 'yes': + success = run_migration() + if success: + print("\nāœ… Migration completed. You can now restart your application.") + else: + print("\nāŒ Migration failed. Please check the errors above.") + else: + print("\nāŒ Migration cancelled.") \ No newline at end of file