Updated PM role with view permissions

This commit is contained in:
2025-10-31 17:31:05 -04:00
parent a4c584468c
commit 6a30f08a5e
7 changed files with 1395 additions and 37 deletions
+331 -26
View File
@@ -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/<int:user_id>/delete', methods=['GET', 'POST'])
@admin_required
def delete_user(user_id):
@@ -2001,35 +2112,72 @@ def demote_user(user_id):
@app.route('/users/<int:user_id>/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
+2 -1
View File
@@ -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
return User, QRCode, QRCodeStyle, Project, AttendanceData, Employee, TimeAttendance, UserProjectPermission, UserLocationPermission
+48
View File
@@ -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'<UserProjectPermission user_id={self.user_id} project_id={self.project_id}>'
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'<UserLocationPermission user_id={self.user_id} location={self.location_name}>'
+263
View File
@@ -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;
}
}
+300 -9
View File
@@ -110,6 +110,114 @@ Code Management{% endblock %} {% block content %}
>
</div>
<!-- Project Manager Permissions Section -->
<div class="permissions-section" id="pmPermissionsSection" style="display: none;">
<div class="section-header">
<h3>
<i class="fas fa-shield-alt"></i>
Project Manager Permissions
</h3>
<p class="section-description">
Follow the steps below to configure access permissions for this Project Manager.
</p>
</div>
<!-- Step 1: Select Projects -->
<div class="permission-step" id="projectStep">
<div class="step-header">
<span class="step-number">1</span>
<div class="step-info">
<h4>Select Projects</h4>
<p>Choose which projects this Project Manager can access</p>
</div>
</div>
<div class="form-group">
<div class="checkbox-group" id="projectCheckboxGroup">
{% if projects %}
{% for project in projects %}
<label class="checkbox-label">
<input
type="checkbox"
name="assigned_projects"
value="{{ project.id }}"
class="checkbox-input project-checkbox"
data-project-name="{{ project.name }}"
>
<span class="checkbox-text">{{ project.name }}</span>
<span class="project-qr-count" title="QR codes in this project">
<i class="fas fa-qrcode"></i> {{ project.qr_count or 0 }}
</span>
</label>
{% endfor %}
{% else %}
<p class="text-muted">No active projects available</p>
{% endif %}
</div>
</div>
<div class="step-footer">
<div class="selection-summary" id="projectSummary">
<i class="fas fa-info-circle"></i>
<span>No projects selected</span>
</div>
</div>
</div>
<!-- Step 2: Select Locations -->
<div class="permission-step" id="locationStep" style="display: none;">
<div class="step-header">
<span class="step-number">2</span>
<div class="step-info">
<h4>Select Locations</h4>
<p>Choose specific locations within the selected projects</p>
</div>
</div>
<div class="form-group">
<div class="location-loading" id="locationLoading" style="display: none;">
<i class="fas fa-spinner fa-spin"></i>
<span>Loading locations...</span>
</div>
<div class="checkbox-group" id="locationCheckboxGroup">
<!-- Locations will be populated dynamically -->
</div>
<div class="location-empty" id="locationEmpty" style="display: none;">
<i class="fas fa-info-circle"></i>
<p>No locations found for the selected projects.</p>
<small>QR codes in these projects may not have location data.</small>
</div>
</div>
<div class="step-footer">
<div class="selection-summary" id="locationSummary">
<i class="fas fa-info-circle"></i>
<span>No locations selected</span>
</div>
</div>
</div>
<!-- Selection Overview -->
<div class="permissions-overview" id="permissionsOverview" style="display: none;">
<div class="overview-header">
<i class="fas fa-check-circle"></i>
<h4>Permission Summary</h4>
</div>
<div class="overview-content">
<div class="overview-item">
<strong>Projects:</strong>
<span id="overviewProjects">-</span>
</div>
<div class="overview-item">
<strong>Locations:</strong>
<span id="overviewLocations">-</span>
</div>
</div>
</div>
</div>
<!-- Role Information Panel -->
<div class="info-panel" id="roleInfo" style="display: none">
<div class="info-header">
@@ -170,6 +278,18 @@ Code Management{% endblock %} {% block content %}
const previewBtn = document.getElementById("previewUser");
const creationSummary = document.getElementById("creationSummary");
// Permission section elements
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');
// Form inputs
const fullNameInput = document.getElementById("full_name");
const emailInput = document.getElementById("email");
@@ -213,17 +333,19 @@ Code Management{% endblock %} {% block content %}
project_manager: {
title: "Project Manager Permissions",
permissions: [
"Create and edit QR codes",
"View all QR codes in the system",
"View assigned projects only",
"View assigned locations only",
"Create and edit QR codes for assigned projects",
"View attendance data for assigned projects/locations",
"Download QR code images",
"Update personal profile information",
"Access dashboard and reports",
"Same permissions as Staff (additional features coming soon)",
"Access dashboard with filtered data",
],
restrictions: [
"Cannot delete QR codes",
"Cannot manage other users",
"Cannot access admin settings",
"Can only view data for assigned projects and locations",
],
},
admin: {
@@ -246,28 +368,23 @@ Code Management{% endblock %} {% block content %}
if (selectedRole && rolePermissions[selectedRole]) {
const roleData = rolePermissions[selectedRole];
let permissionsHtml = `
<h4>${roleData.title}</h4>
<div class="permissions-section">
<h5><i class="fas fa-check-circle text-success"></i> Permissions</h5>
<ul class="permissions-list">
`;
roleData.permissions.forEach((permission) => {
permissionsHtml += `<li><i class="fas fa-check"></i> ${permission}</li>`;
});
permissionsHtml += `
</ul>
<h5><i class="fas fa-times-circle text-warning"></i> Restrictions</h5>
<ul class="restrictions-list">
`;
roleData.restrictions.forEach((restriction) => {
permissionsHtml += `<li><i class="fas fa-times"></i> ${restriction}</li>`;
});
permissionsHtml += `</ul></div>`;
roleContent.innerHTML = permissionsHtml;
@@ -277,8 +394,182 @@ Code Management{% endblock %} {% block content %}
roleInfo.style.display = "none";
roleInfo.classList.remove("fade-in");
}
// Show/hide Project Manager permissions section
if (selectedRole === 'project_manager') {
pmSection.style.display = 'block';
setupPermissionFlow();
} else {
pmSection.style.display = 'none';
resetPermissionFlow();
}
});
// Setup cascading permission flow
function setupPermissionFlow() {
// Reset state
locationStep.style.display = 'none';
permissionsOverview.style.display = 'none';
locationCheckboxGroup.innerHTML = '';
// Add event listeners to project checkboxes
projectCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', handleProjectSelection);
});
}
// Handle project selection changes
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 = `
<i class="fas fa-check-circle" style="color: #10b981;"></i>
<span><strong>${selectedProjects.length}</strong> project${selectedProjects.length > 1 ? 's' : ''} selected</span>
`;
// Show location step and load locations
locationStep.style.display = 'block';
loadLocationsByProjects(selectedProjects.map(p => p.id));
} else {
projectSummary.innerHTML = `
<i class="fas fa-info-circle"></i>
<span>No projects selected</span>
`;
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 = `
<i class="fas fa-info-circle"></i>
<span>Loading locations...</span>
`;
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 => `
<label class="checkbox-label">
<input
type="checkbox"
name="assigned_locations"
value="${location}"
class="checkbox-input location-checkbox"
>
<span class="checkbox-text">${location}</span>
</label>
`).join('');
// Add event listeners to location checkboxes
document.querySelectorAll('.location-checkbox').forEach(cb => {
cb.addEventListener('change', handleLocationSelection);
});
locationSummary.innerHTML = `
<i class="fas fa-info-circle"></i>
<span>${data.count} location${data.count > 1 ? 's' : ''} available</span>
`;
} else {
locationEmpty.style.display = 'block';
locationSummary.innerHTML = `
<i class="fas fa-exclamation-circle"></i>
<span>No locations available</span>
`;
}
updatePermissionsOverview();
} catch (error) {
console.error('Error loading locations:', error);
locationLoading.style.display = 'none';
locationEmpty.style.display = 'block';
locationSummary.innerHTML = `
<i class="fas fa-exclamation-triangle" style="color: #ef4444;"></i>
<span>Error loading locations</span>
`;
}
}
// Handle location selection changes
function handleLocationSelection() {
const selectedLocations = Array.from(document.querySelectorAll('.location-checkbox:checked'));
if (selectedLocations.length > 0) {
locationSummary.innerHTML = `
<i class="fas fa-check-circle" style="color: #10b981;"></i>
<span><strong>${selectedLocations.length}</strong> location${selectedLocations.length > 1 ? 's' : ''} selected</span>
`;
} else {
const totalLocations = document.querySelectorAll('.location-checkbox').length;
locationSummary.innerHTML = `
<i class="fas fa-info-circle"></i>
<span>${totalLocations} location${totalLocations > 1 ? 's' : ''} available</span>
`;
}
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 = '';
}
// Password strength indicator
passwordInput.addEventListener("input", function () {
const password = this.value;
+330 -1
View File
@@ -51,7 +51,8 @@
<i class="fas fa-user"></i>
Username
</label>
<input type="text" id="username" value="{{ user.username }}" disabled>
<input type="text" id="username_display" value="{{ user.username }}" disabled>
<input type="hidden" name="username" value="{{ user.username }}">
<small class="form-help">Username cannot be changed</small>
</div>
@@ -81,6 +82,114 @@
</div>
</div>
<!-- Project Manager Permissions Section -->
<div class="permissions-section" id="pmPermissionsSection" style="display: none;">
<div class="section-header">
<h3>
<i class="fas fa-shield-alt"></i>
Project Manager Permissions
</h3>
<p class="section-description">
Follow the steps below to configure access permissions for this Project Manager.
</p>
</div>
<!-- Step 1: Select Projects -->
<div class="permission-step" id="projectStep">
<div class="step-header">
<span class="step-number">1</span>
<div class="step-info">
<h4>Select Projects</h4>
<p>Choose which projects this Project Manager can access</p>
</div>
</div>
<div class="form-group">
<div class="checkbox-group" id="projectCheckboxGroup">
{% if projects %}
{% for project in projects %}
<label class="checkbox-label">
<input
type="checkbox"
name="assigned_projects"
value="{{ project.id }}"
class="checkbox-input project-checkbox"
data-project-name="{{ project.name }}"
>
<span class="checkbox-text">{{ project.name }}</span>
<span class="project-qr-count" title="QR codes in this project">
<i class="fas fa-qrcode"></i> {{ project.qr_count or 0 }}
</span>
</label>
{% endfor %}
{% else %}
<p class="text-muted">No active projects available</p>
{% endif %}
</div>
</div>
<div class="step-footer">
<div class="selection-summary" id="projectSummary">
<i class="fas fa-info-circle"></i>
<span>No projects selected</span>
</div>
</div>
</div>
<!-- Step 2: Select Locations -->
<div class="permission-step" id="locationStep" style="display: none;">
<div class="step-header">
<span class="step-number">2</span>
<div class="step-info">
<h4>Select Locations</h4>
<p>Choose specific locations within the selected projects</p>
</div>
</div>
<div class="form-group">
<div class="location-loading" id="locationLoading" style="display: none;">
<i class="fas fa-spinner fa-spin"></i>
<span>Loading locations...</span>
</div>
<div class="checkbox-group" id="locationCheckboxGroup">
<!-- Locations will be populated dynamically -->
</div>
<div class="location-empty" id="locationEmpty" style="display: none;">
<i class="fas fa-info-circle"></i>
<p>No locations found for the selected projects.</p>
<small>QR codes in these projects may not have location data.</small>
</div>
</div>
<div class="step-footer">
<div class="selection-summary" id="locationSummary">
<i class="fas fa-info-circle"></i>
<span>No locations selected</span>
</div>
</div>
</div>
<!-- Selection Overview -->
<div class="permissions-overview" id="permissionsOverview" style="display: none;">
<div class="overview-header">
<i class="fas fa-check-circle"></i>
<h4>Permission Summary</h4>
</div>
<div class="overview-content">
<div class="overview-item">
<strong>Projects:</strong>
<span id="overviewProjects">-</span>
</div>
<div class="overview-item">
<strong>Locations:</strong>
<span id="overviewLocations">-</span>
</div>
</div>
</div>
</div>
<!-- Password Section -->
<div class="password-section">
<h3>
@@ -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 = `
<i class="fas fa-check-circle" style="color: #10b981;"></i>
<span><strong>${selectedProjects.length}</strong> project${selectedProjects.length > 1 ? 's' : ''} selected</span>
`;
// Show location step and load locations
locationStep.style.display = 'block';
await loadLocationsByProjects(selectedProjects.map(p => p.id));
} else {
projectSummary.innerHTML = `
<i class="fas fa-info-circle"></i>
<span>No projects selected</span>
`;
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 = `
<i class="fas fa-info-circle"></i>
<span>Loading locations...</span>
`;
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 => `
<label class="checkbox-label">
<input
type="checkbox"
name="assigned_locations"
value="${location}"
class="checkbox-input location-checkbox"
>
<span class="checkbox-text">${location}</span>
</label>
`).join('');
// Add event listeners to location checkboxes
document.querySelectorAll('.location-checkbox').forEach(cb => {
cb.addEventListener('change', handleLocationSelection);
});
locationSummary.innerHTML = `
<i class="fas fa-info-circle"></i>
<span>${data.count} location${data.count > 1 ? 's' : ''} available</span>
`;
} else {
locationEmpty.style.display = 'block';
locationSummary.innerHTML = `
<i class="fas fa-exclamation-circle"></i>
<span>No locations available</span>
`;
}
updatePermissionsOverview();
} catch (error) {
console.error('Error loading locations:', error);
locationLoading.style.display = 'none';
locationEmpty.style.display = 'block';
locationSummary.innerHTML = `
<i class="fas fa-exclamation-triangle" style="color: #ef4444;"></i>
<span>Error loading locations</span>
`;
}
}
// Handle location selection changes
function handleLocationSelection() {
const selectedLocations = Array.from(document.querySelectorAll('.location-checkbox:checked'));
if (selectedLocations.length > 0) {
locationSummary.innerHTML = `
<i class="fas fa-check-circle" style="color: #10b981;"></i>
<span><strong>${selectedLocations.length}</strong> location${selectedLocations.length > 1 ? 's' : ''} selected</span>
`;
} else {
const totalLocations = document.querySelectorAll('.location-checkbox').length;
locationSummary.innerHTML = `
<i class="fas fa-info-circle"></i>
<span>${totalLocations} location${totalLocations > 1 ? 's' : ''} available</span>
`;
}
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);
+121
View File
@@ -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.")