Spaces:
Running
Running
sabarish commited on
Commit ·
e45ddff
0
Parent(s):
Initial commit
Browse files- .gitignore +9 -0
- README.md +35 -0
- app.py +89 -0
- config.py +20 -0
- create_admin.py +22 -0
- data/sample_data.csv +11 -0
- models.py +55 -0
- requirements.txt +16 -0
- reset_db.py +23 -0
- routes/admin.py +163 -0
- routes/auth.py +63 -0
- routes/dashboard.py +174 -0
- routes/feedback.py +37 -0
- routes/profile.py +24 -0
- routes/reports.py +122 -0
- routes/training.py +59 -0
- routes/upload.py +61 -0
- scripts/train_model.py +114 -0
- sql/schema.sql +52 -0
- static/css/style.css +167 -0
- static/js/charts.js +160 -0
- static/js/main.js +66 -0
- templates/admin/create_user.html +101 -0
- templates/admin/manage_users.html +147 -0
- templates/admin/training.html +149 -0
- templates/auth/login.html +49 -0
- templates/auth/register.html +65 -0
- templates/base.html +221 -0
- templates/dashboard/admin.html +295 -0
- templates/dashboard/hod_staff.html +159 -0
- templates/dashboard/profile.html +109 -0
- templates/dashboard/student.html +78 -0
- templates/feedback/submit.html +47 -0
- templates/upload/index.html +138 -0
- test_app.py +99 -0
- test_model.py +10 -0
- utils/decorators.py +19 -0
- utils/file_processor.py +129 -0
- utils/nlp_utils.py +119 -0
.gitignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
.env
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
uploads/
|
| 5 |
+
*.sqlite
|
| 6 |
+
*.sqlite3
|
| 7 |
+
instance/
|
| 8 |
+
test_out.pdf
|
| 9 |
+
*.log
|
README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AI-Based Sentiment Analysis System
|
| 2 |
+
|
| 3 |
+
## Prerequisites
|
| 4 |
+
- Python 3.8+
|
| 5 |
+
- MySQL Server
|
| 6 |
+
|
| 7 |
+
## Setup Instructions
|
| 8 |
+
|
| 9 |
+
1. **Clone or Extract the Repository**
|
| 10 |
+
2. **Install Dependencies**
|
| 11 |
+
```bash
|
| 12 |
+
pip install -r requirements.txt
|
| 13 |
+
```
|
| 14 |
+
3. **Download NLTK Data**
|
| 15 |
+
Run the following Python script to download necessary NLTK corpora:
|
| 16 |
+
```python
|
| 17 |
+
import nltk
|
| 18 |
+
nltk.download('stopwords')
|
| 19 |
+
nltk.download('punkt')
|
| 20 |
+
```
|
| 21 |
+
4. **Database Setup**
|
| 22 |
+
- Ensure MySQL is running.
|
| 23 |
+
- Run the schema script or let SQLAlchemy create the tables automatically.
|
| 24 |
+
- You can run the script manually: `mysql -u root -p < sql/schema.sql` (Adjust credentials as needed).
|
| 25 |
+
- If using a different database name or credentials, update the `.env` file or `config.py`.
|
| 26 |
+
5. **Run the Application**
|
| 27 |
+
```bash
|
| 28 |
+
python app.py
|
| 29 |
+
```
|
| 30 |
+
The application will start on `http://127.0.0.1:5000`.
|
| 31 |
+
|
| 32 |
+
## Default Admin Account
|
| 33 |
+
- **Email:** admin@example.com
|
| 34 |
+
- **Password:** admin123
|
| 35 |
+
*(Note: If the DB is populated from `schema.sql`, this account is auto-created. Otherwise, you can register a new account from the UI and change the role in the database manually).*
|
app.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from flask import Flask
|
| 3 |
+
from markupsafe import Markup
|
| 4 |
+
from config import Config
|
| 5 |
+
from models import db, bcrypt, login_manager
|
| 6 |
+
from flask_mail import Mail
|
| 7 |
+
|
| 8 |
+
# Initialize extensions outside create_app so blueprints can import them if needed
|
| 9 |
+
# (Though best practice is usually to init in create_app, we'll keep the existing structure and add mail)
|
| 10 |
+
mail = Mail()
|
| 11 |
+
|
| 12 |
+
def create_app(config_class=Config):
|
| 13 |
+
app = Flask(__name__)
|
| 14 |
+
app.config.from_object(config_class)
|
| 15 |
+
|
| 16 |
+
# Mail configuration explicitly set to prevent crashing if config.py is sparse
|
| 17 |
+
app.config['MAIL_SERVER'] = os.environ.get('MAIL_SERVER', 'smtp.googlemail.com')
|
| 18 |
+
app.config['MAIL_PORT'] = int(os.environ.get('MAIL_PORT', 587))
|
| 19 |
+
app.config['MAIL_USE_TLS'] = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1']
|
| 20 |
+
app.config['MAIL_USERNAME'] = os.environ.get('MAIL_USERNAME', 'your_email@gmail.com')
|
| 21 |
+
app.config['MAIL_PASSWORD'] = os.environ.get('MAIL_PASSWORD', 'your_app_password')
|
| 22 |
+
|
| 23 |
+
# Initialize extensions
|
| 24 |
+
db.init_app(app)
|
| 25 |
+
bcrypt.init_app(app)
|
| 26 |
+
login_manager.init_app(app)
|
| 27 |
+
mail.init_app(app)
|
| 28 |
+
|
| 29 |
+
login_manager.login_view = 'auth.login'
|
| 30 |
+
login_manager.login_message_category = 'info'
|
| 31 |
+
|
| 32 |
+
# Create upload directory if it doesn't exist
|
| 33 |
+
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
| 34 |
+
|
| 35 |
+
# Register blueprints (we will create these next)
|
| 36 |
+
from routes.auth import auth_bp
|
| 37 |
+
from routes.dashboard import dashboard_bp
|
| 38 |
+
from routes.feedback import feedback_bp
|
| 39 |
+
from routes.reports import reports_bp
|
| 40 |
+
from routes.upload import upload_bp
|
| 41 |
+
from routes.admin import admin_bp
|
| 42 |
+
from routes.profile import profile_bp
|
| 43 |
+
from routes.training import training_bp
|
| 44 |
+
|
| 45 |
+
app.register_blueprint(auth_bp, url_prefix='/auth')
|
| 46 |
+
app.register_blueprint(dashboard_bp, url_prefix='/dashboard')
|
| 47 |
+
app.register_blueprint(feedback_bp, url_prefix='/feedback')
|
| 48 |
+
app.register_blueprint(reports_bp, url_prefix='/reports')
|
| 49 |
+
app.register_blueprint(upload_bp, url_prefix='/upload')
|
| 50 |
+
app.register_blueprint(admin_bp, url_prefix='/admin')
|
| 51 |
+
app.register_blueprint(profile_bp, url_prefix='/profile')
|
| 52 |
+
app.register_blueprint(training_bp, url_prefix='/admin')
|
| 53 |
+
|
| 54 |
+
# Root route redirects to dashboard or login
|
| 55 |
+
@app.route('/')
|
| 56 |
+
def index():
|
| 57 |
+
from flask import redirect, url_for
|
| 58 |
+
from flask_login import current_user
|
| 59 |
+
if current_user.is_authenticated:
|
| 60 |
+
return redirect(url_for('dashboard.index'))
|
| 61 |
+
return redirect(url_for('auth.login'))
|
| 62 |
+
|
| 63 |
+
# Custom Jinja filters
|
| 64 |
+
@app.template_filter('sentiment_color')
|
| 65 |
+
def sentiment_color(sentiment):
|
| 66 |
+
return {
|
| 67 |
+
'Positive': 'text-green-500',
|
| 68 |
+
'Negative': 'text-red-500',
|
| 69 |
+
'Neutral': 'text-gray-500'
|
| 70 |
+
}.get(sentiment, 'text-gray-500')
|
| 71 |
+
|
| 72 |
+
@app.template_filter('sentiment_badge')
|
| 73 |
+
def sentiment_badge(sentiment):
|
| 74 |
+
colors = {
|
| 75 |
+
'Positive': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
|
| 76 |
+
'Negative': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
|
| 77 |
+
'Neutral': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
| 78 |
+
}
|
| 79 |
+
classes = colors.get(sentiment, colors['Neutral'])
|
| 80 |
+
return Markup(f'<span class="px-2 py-1 text-xs font-medium rounded-full {classes}">{sentiment}</span>')
|
| 81 |
+
|
| 82 |
+
return app
|
| 83 |
+
|
| 84 |
+
if __name__ == '__main__':
|
| 85 |
+
app = create_app()
|
| 86 |
+
with app.app_context():
|
| 87 |
+
# Create database tables if they don't exist
|
| 88 |
+
db.create_all()
|
| 89 |
+
app.run(debug=True, port=5000)
|
config.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
basedir = os.path.abspath(os.path.dirname(__file__))
|
| 5 |
+
load_dotenv(os.path.join(basedir, '.env'))
|
| 6 |
+
|
| 7 |
+
class Config:
|
| 8 |
+
SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess-super-secret'
|
| 9 |
+
|
| 10 |
+
# MySQL Connection String Example: mysql+pymysql://user:password@localhost/dbname
|
| 11 |
+
# Fallback to SQLite if MySQL is not configured for easy testing
|
| 12 |
+
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
|
| 13 |
+
'mysql+pymysql://root:root@localhost/sentiment_db'
|
| 14 |
+
|
| 15 |
+
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
| 16 |
+
|
| 17 |
+
# File upload handling
|
| 18 |
+
UPLOAD_FOLDER = os.path.join(basedir, 'uploads')
|
| 19 |
+
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16 MB max limit
|
| 20 |
+
ALLOWED_EXTENSIONS = {'csv', 'xls', 'xlsx'}
|
create_admin.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app import create_app
|
| 2 |
+
from models import db, User
|
| 3 |
+
|
| 4 |
+
app = create_app()
|
| 5 |
+
|
| 6 |
+
with app.app_context():
|
| 7 |
+
# Check if admin exists
|
| 8 |
+
admin_user = User.query.filter_by(role='Admin').first()
|
| 9 |
+
|
| 10 |
+
if not admin_user:
|
| 11 |
+
print("Creating default admin account...")
|
| 12 |
+
admin = User(name="System Administrator", email="admin@neurosent.com", role="Admin", department="IT")
|
| 13 |
+
admin.set_password("admin123")
|
| 14 |
+
db.session.add(admin)
|
| 15 |
+
db.session.commit()
|
| 16 |
+
print("Admin account created! Email: admin@neurosent.com | Pass: admin123")
|
| 17 |
+
else:
|
| 18 |
+
# Update existing admin password to be sure
|
| 19 |
+
print(f"Admin already exists: {admin_user.email}")
|
| 20 |
+
admin_user.set_password("admin123")
|
| 21 |
+
db.session.commit()
|
| 22 |
+
print("Admin password reset to: admin123")
|
data/sample_data.csv
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"id","review_text","department","date"
|
| 2 |
+
1,"The new course material is absolutely fantastic and very easy to follow!","Computer Science","2023-11-01"
|
| 3 |
+
2,"I am confused by the recent grading system, it doesn't make any sense.","Mathematics","2023-11-02"
|
| 4 |
+
3,"The library facilities are okay, but the wifi is sometimes slow.","General","2023-11-03"
|
| 5 |
+
4,"Excellent support from the teaching staff during project work.","Engineering","2023-11-04"
|
| 6 |
+
5,"Terrible experience with the online portal, it crashed during my exam.","IT Support","2023-11-05"
|
| 7 |
+
6,"I love the campus environment, very peaceful and great for studying.","General","2023-11-06"
|
| 8 |
+
7,"The food in the cafeteria is too expensive and lacks variety.","Facilities","2023-11-07"
|
| 9 |
+
8,"Professor Smith's lectures are incredibly inspiring. Highly recommend!","Physics","2023-11-08"
|
| 10 |
+
9,"The assignment deadlines are unreasonable and stressful.","Management","2023-11-09"
|
| 11 |
+
10,"Standard procedure followed without any issues.","Administration","2023-11-10"
|
models.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask_sqlalchemy import SQLAlchemy
|
| 2 |
+
from flask_bcrypt import Bcrypt
|
| 3 |
+
from flask_login import UserMixin, LoginManager
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
db = SQLAlchemy()
|
| 7 |
+
bcrypt = Bcrypt()
|
| 8 |
+
login_manager = LoginManager()
|
| 9 |
+
|
| 10 |
+
@login_manager.user_loader
|
| 11 |
+
def load_user(user_id):
|
| 12 |
+
return User.query.get(int(user_id))
|
| 13 |
+
|
| 14 |
+
class User(db.Model, UserMixin):
|
| 15 |
+
__tablename__ = 'users'
|
| 16 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 17 |
+
name = db.Column(db.String(100), nullable=False)
|
| 18 |
+
email = db.Column(db.String(120), unique=True, nullable=False)
|
| 19 |
+
password_hash = db.Column(db.String(255), nullable=False)
|
| 20 |
+
role = db.Column(db.Enum('Admin', 'HOD', 'Staff', 'Student'), nullable=False, default='Student')
|
| 21 |
+
department = db.Column(db.String(100))
|
| 22 |
+
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())
|
| 23 |
+
|
| 24 |
+
feedbacks = db.relationship('Feedback', backref='author', lazy=True)
|
| 25 |
+
uploads = db.relationship('Upload', backref='uploader', lazy=True)
|
| 26 |
+
|
| 27 |
+
def set_password(self, password):
|
| 28 |
+
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
| 29 |
+
|
| 30 |
+
def check_password(self, password):
|
| 31 |
+
return bcrypt.check_password_hash(self.password_hash, password)
|
| 32 |
+
|
| 33 |
+
class Upload(db.Model):
|
| 34 |
+
__tablename__ = 'uploads'
|
| 35 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 36 |
+
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
| 37 |
+
filename = db.Column(db.String(255), nullable=False)
|
| 38 |
+
total_rows = db.Column(db.Integer, default=0)
|
| 39 |
+
processed_rows = db.Column(db.Integer, default=0)
|
| 40 |
+
status = db.Column(db.Enum('Pending', 'Processing', 'Completed', 'Failed'), default='Pending')
|
| 41 |
+
upload_date = db.Column(db.DateTime, default=db.func.current_timestamp())
|
| 42 |
+
|
| 43 |
+
feedbacks = db.relationship('Feedback', backref='upload_source', lazy=True, cascade="all, delete-orphan")
|
| 44 |
+
|
| 45 |
+
class Feedback(db.Model):
|
| 46 |
+
__tablename__ = 'feedback'
|
| 47 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 48 |
+
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
| 49 |
+
upload_id = db.Column(db.Integer, db.ForeignKey('uploads.id'), nullable=True)
|
| 50 |
+
original_text = db.Column(db.Text, nullable=False)
|
| 51 |
+
cleaned_text = db.Column(db.Text)
|
| 52 |
+
sentiment = db.Column(db.Enum('Positive', 'Negative', 'Neutral'), nullable=False)
|
| 53 |
+
sentiment_score = db.Column(db.Float, nullable=False)
|
| 54 |
+
department_category = db.Column(db.String(100))
|
| 55 |
+
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())
|
requirements.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask>=3.0.0
|
| 2 |
+
Flask-SQLAlchemy>=3.1.1
|
| 3 |
+
Flask-Bcrypt>=1.0.1
|
| 4 |
+
Flask-Login>=0.6.3
|
| 5 |
+
pymysql>=1.1.0
|
| 6 |
+
pandas>=2.1.3
|
| 7 |
+
textblob>=0.17.1
|
| 8 |
+
nltk>=3.8.1
|
| 9 |
+
openpyxl>=3.1.2
|
| 10 |
+
xlrd>=2.0.1
|
| 11 |
+
reportlab>=4.0.7
|
| 12 |
+
python-dotenv>=1.0.0
|
| 13 |
+
Werkzeug>=3.0.1
|
| 14 |
+
transformers>=4.35.0
|
| 15 |
+
torch>=2.1.0
|
| 16 |
+
scipy>=1.11.0
|
reset_db.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from app import create_app
|
| 3 |
+
from models import db, User
|
| 4 |
+
|
| 5 |
+
app = create_app()
|
| 6 |
+
|
| 7 |
+
with app.app_context():
|
| 8 |
+
print("Dropping all existing tables...")
|
| 9 |
+
db.drop_all()
|
| 10 |
+
print("Creating all tables based on new models...")
|
| 11 |
+
db.create_all()
|
| 12 |
+
|
| 13 |
+
print("Seeding default Admin user...")
|
| 14 |
+
admin = User(
|
| 15 |
+
name="System Admin",
|
| 16 |
+
email="admin@neurosent.com",
|
| 17 |
+
role="Admin",
|
| 18 |
+
department="System Operations"
|
| 19 |
+
)
|
| 20 |
+
admin.set_password("admin123")
|
| 21 |
+
db.session.add(admin)
|
| 22 |
+
db.session.commit()
|
| 23 |
+
print("Database reset successfully! Default admin created (admin@neurosent.com / admin123).")
|
routes/admin.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app
|
| 2 |
+
from flask_login import login_required, current_user
|
| 3 |
+
import random
|
| 4 |
+
import string
|
| 5 |
+
from threading import Thread
|
| 6 |
+
from flask_mail import Message
|
| 7 |
+
from models import db, User
|
| 8 |
+
|
| 9 |
+
admin_bp = Blueprint('admin', __name__)
|
| 10 |
+
|
| 11 |
+
def send_async_email(app, msg):
|
| 12 |
+
with app.app_context():
|
| 13 |
+
try:
|
| 14 |
+
from app import mail
|
| 15 |
+
mail.send(msg)
|
| 16 |
+
print(f"Background email sent to {msg.recipients}")
|
| 17 |
+
except Exception as e:
|
| 18 |
+
print(f"Failed to send background email: {e}")
|
| 19 |
+
|
| 20 |
+
@admin_bp.route('/create-user', methods=['GET', 'POST'])
|
| 21 |
+
@login_required
|
| 22 |
+
def create_user():
|
| 23 |
+
# Security: Ensure ONLY Admins can access this page
|
| 24 |
+
if current_user.role != 'Admin':
|
| 25 |
+
flash('Access denied. You do not have permission to view this page.', 'danger')
|
| 26 |
+
return redirect(url_for('dashboard.index'))
|
| 27 |
+
|
| 28 |
+
if request.method == 'POST':
|
| 29 |
+
name = request.form.get('name')
|
| 30 |
+
email = request.form.get('email')
|
| 31 |
+
password = request.form.get('password')
|
| 32 |
+
role = request.form.get('role')
|
| 33 |
+
department = request.form.get('department')
|
| 34 |
+
|
| 35 |
+
# Validation
|
| 36 |
+
if not all([name, email, password, role]):
|
| 37 |
+
flash('Please fill in all required fields.', 'warning')
|
| 38 |
+
return redirect(url_for('admin.create_user'))
|
| 39 |
+
|
| 40 |
+
# Check if user already exists
|
| 41 |
+
if User.query.filter_by(email=email).first():
|
| 42 |
+
flash('A user with that email already exists.', 'danger')
|
| 43 |
+
return redirect(url_for('admin.create_user'))
|
| 44 |
+
|
| 45 |
+
# Create user
|
| 46 |
+
try:
|
| 47 |
+
new_user = User(name=name, email=email, role=role, department=department)
|
| 48 |
+
new_user.set_password(password)
|
| 49 |
+
db.session.add(new_user)
|
| 50 |
+
db.session.commit()
|
| 51 |
+
|
| 52 |
+
# Send Welcome Email asynchronously
|
| 53 |
+
msg = Message(
|
| 54 |
+
"Welcome to NeuroSent - Your Account is Ready",
|
| 55 |
+
recipients=[email]
|
| 56 |
+
)
|
| 57 |
+
msg.body = f"Hello {name},\n\nYour {role} account has been created.\nEmail: {email}\nPassword: {password}\n\nPlease login and change your password immediately."
|
| 58 |
+
msg.html = f"""
|
| 59 |
+
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 10px;">
|
| 60 |
+
<h2 style="color: #4f46e5;">Welcome to NeuroSent Hub!</h2>
|
| 61 |
+
<p>Hello <strong>{name}</strong>,</p>
|
| 62 |
+
<p>An administrator has provisioned a new <strong>{role}</strong> account for you.</p>
|
| 63 |
+
<div style="background-color: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
| 64 |
+
<p style="margin: 0;"><strong>Email:</strong> {email}</p>
|
| 65 |
+
<p style="margin: 5px 0 0 0;"><strong>Temporary Password:</strong> {password}</p>
|
| 66 |
+
</div>
|
| 67 |
+
<p style="color: #ef4444; font-size: 0.9em;">For security reasons, please login and update your password immediately.</p>
|
| 68 |
+
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;">
|
| 69 |
+
<p style="font-size: 0.8em; color: #6b7280;">This is an automated message. Please do not reply.</p>
|
| 70 |
+
</div>
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
Thread(target=send_async_email, args=(current_app._get_current_object(), msg)).start()
|
| 74 |
+
|
| 75 |
+
flash(f'Successfully created {role} account for {name}. Welcome email queued.', 'success')
|
| 76 |
+
return redirect(url_for('admin.create_user'))
|
| 77 |
+
except Exception as e:
|
| 78 |
+
db.session.rollback()
|
| 79 |
+
flash(f'An error occurred: {str(e)}', 'danger')
|
| 80 |
+
|
| 81 |
+
return render_template('admin/create_user.html')
|
| 82 |
+
|
| 83 |
+
@admin_bp.route('/manage-users', methods=['GET'])
|
| 84 |
+
@login_required
|
| 85 |
+
def manage_users():
|
| 86 |
+
if current_user.role != 'Admin':
|
| 87 |
+
flash('Access denied. Administrator privileges required.', 'danger')
|
| 88 |
+
return redirect(url_for('dashboard.index'))
|
| 89 |
+
|
| 90 |
+
users = User.query.all()
|
| 91 |
+
return render_template('admin/manage_users.html', users=users)
|
| 92 |
+
|
| 93 |
+
@admin_bp.route('/reset-password/<int:user_id>', methods=['POST'])
|
| 94 |
+
@login_required
|
| 95 |
+
def reset_password(user_id):
|
| 96 |
+
if current_user.role != 'Admin':
|
| 97 |
+
flash('Access denied. Administrator privileges required.', 'danger')
|
| 98 |
+
return redirect(url_for('dashboard.index'))
|
| 99 |
+
|
| 100 |
+
user = User.query.get_or_404(user_id)
|
| 101 |
+
|
| 102 |
+
custom_password = request.form.get('new_password', '').strip()
|
| 103 |
+
|
| 104 |
+
if custom_password:
|
| 105 |
+
new_password = custom_password
|
| 106 |
+
msg = f'Password for {user.email} has been manually changed successfully.'
|
| 107 |
+
else:
|
| 108 |
+
# Generate random 8-char password if none provided
|
| 109 |
+
new_password = ''.join(random.choices(string.ascii_letters + string.digits, k=8))
|
| 110 |
+
msg = f'Password for {user.email} auto-generated successfully. New password: {new_password}'
|
| 111 |
+
|
| 112 |
+
try:
|
| 113 |
+
user.set_password(new_password)
|
| 114 |
+
db.session.commit()
|
| 115 |
+
|
| 116 |
+
# Send Notification Email asynchronously
|
| 117 |
+
email_msg = Message(
|
| 118 |
+
"NeuroSent Security: Password Reset",
|
| 119 |
+
recipients=[user.email]
|
| 120 |
+
)
|
| 121 |
+
email_msg.html = f"""
|
| 122 |
+
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 10px;">
|
| 123 |
+
<h2 style="color: #f59e0b;">Security Alert: Password Changed</h2>
|
| 124 |
+
<p>Hello <strong>{user.name}</strong>,</p>
|
| 125 |
+
<p>Your password has been successfully reset by an administrator.</p>
|
| 126 |
+
<div style="background-color: #fef3c7; padding: 15px; border-radius: 5px; border-left: 4px solid #f59e0b; margin: 20px 0;">
|
| 127 |
+
<p style="margin: 0;"><strong>New Password:</strong> {new_password}</p>
|
| 128 |
+
</div>
|
| 129 |
+
<p>If you did not request this change, please contact IT support immediately.</p>
|
| 130 |
+
</div>
|
| 131 |
+
"""
|
| 132 |
+
Thread(target=send_async_email, args=(current_app._get_current_object(), email_msg)).start()
|
| 133 |
+
|
| 134 |
+
flash(msg + " An email notification has been queued.", 'success')
|
| 135 |
+
except Exception as e:
|
| 136 |
+
db.session.rollback()
|
| 137 |
+
flash(f'Error resetting password: {str(e)}', 'danger')
|
| 138 |
+
|
| 139 |
+
return redirect(url_for('admin.manage_users'))
|
| 140 |
+
|
| 141 |
+
@admin_bp.route('/delete-user/<int:user_id>', methods=['POST'])
|
| 142 |
+
@login_required
|
| 143 |
+
def delete_user(user_id):
|
| 144 |
+
if current_user.role != 'Admin':
|
| 145 |
+
flash('Access denied. Administrator privileges required.', 'danger')
|
| 146 |
+
return redirect(url_for('dashboard.index'))
|
| 147 |
+
|
| 148 |
+
user = User.query.get_or_404(user_id)
|
| 149 |
+
|
| 150 |
+
# Prevent admin from deleting themselves
|
| 151 |
+
if user.id == current_user.id:
|
| 152 |
+
flash('You cannot delete your own admin account.', 'danger')
|
| 153 |
+
return redirect(url_for('admin.manage_users'))
|
| 154 |
+
|
| 155 |
+
try:
|
| 156 |
+
db.session.delete(user)
|
| 157 |
+
db.session.commit()
|
| 158 |
+
flash(f'User {user.email} has been permanently deleted.', 'success')
|
| 159 |
+
except Exception as e:
|
| 160 |
+
db.session.rollback()
|
| 161 |
+
flash(f'Error deleting user: {str(e)}', 'danger')
|
| 162 |
+
|
| 163 |
+
return redirect(url_for('admin.manage_users'))
|
routes/auth.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
| 2 |
+
from flask_login import login_user, logout_user, current_user
|
| 3 |
+
from models import db, User
|
| 4 |
+
from urllib.parse import urlsplit
|
| 5 |
+
|
| 6 |
+
auth_bp = Blueprint('auth', __name__)
|
| 7 |
+
|
| 8 |
+
@auth_bp.route('/login', methods=['GET', 'POST'])
|
| 9 |
+
def login():
|
| 10 |
+
if current_user.is_authenticated:
|
| 11 |
+
return redirect(url_for('dashboard.index'))
|
| 12 |
+
|
| 13 |
+
if request.method == 'POST':
|
| 14 |
+
email = request.form.get('email')
|
| 15 |
+
password = request.form.get('password')
|
| 16 |
+
|
| 17 |
+
user = User.query.filter_by(email=email).first()
|
| 18 |
+
if user and user.check_password(password):
|
| 19 |
+
login_user(user)
|
| 20 |
+
flash('Logged in successfully.', 'success')
|
| 21 |
+
|
| 22 |
+
# Redirect to next page if exists
|
| 23 |
+
next_page = request.args.get('next')
|
| 24 |
+
if not next_page or urlsplit(next_page).netloc != '':
|
| 25 |
+
next_page = url_for('dashboard.index')
|
| 26 |
+
return redirect(next_page)
|
| 27 |
+
else:
|
| 28 |
+
flash('Invalid email or password.', 'danger')
|
| 29 |
+
|
| 30 |
+
return render_template('auth/login.html')
|
| 31 |
+
|
| 32 |
+
@auth_bp.route('/register', methods=['GET', 'POST'])
|
| 33 |
+
def register():
|
| 34 |
+
if current_user.is_authenticated:
|
| 35 |
+
return redirect(url_for('dashboard.index'))
|
| 36 |
+
|
| 37 |
+
if request.method == 'POST':
|
| 38 |
+
name = request.form.get('name')
|
| 39 |
+
email = request.form.get('email')
|
| 40 |
+
password = request.form.get('password')
|
| 41 |
+
role = request.form.get('role', 'Student') # Default to Student
|
| 42 |
+
department = request.form.get('department')
|
| 43 |
+
|
| 44 |
+
# Check if user already exists
|
| 45 |
+
if User.query.filter_by(email=email).first():
|
| 46 |
+
flash('Email already registered. Please login.', 'danger')
|
| 47 |
+
return redirect(url_for('auth.login'))
|
| 48 |
+
|
| 49 |
+
new_user = User(name=name, email=email, role=role, department=department)
|
| 50 |
+
new_user.set_password(password)
|
| 51 |
+
db.session.add(new_user)
|
| 52 |
+
db.session.commit()
|
| 53 |
+
|
| 54 |
+
flash('Registration successful. You can now login.', 'success')
|
| 55 |
+
return redirect(url_for('auth.login'))
|
| 56 |
+
|
| 57 |
+
return render_template('auth/register.html')
|
| 58 |
+
|
| 59 |
+
@auth_bp.route('/logout')
|
| 60 |
+
def logout():
|
| 61 |
+
logout_user()
|
| 62 |
+
flash('You have been logged out.', 'info')
|
| 63 |
+
return redirect(url_for('auth.login'))
|
routes/dashboard.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, render_template, jsonify
|
| 2 |
+
from flask_login import login_required, current_user
|
| 3 |
+
from models import Feedback
|
| 4 |
+
from sqlalchemy import func
|
| 5 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 6 |
+
import pandas as pd
|
| 7 |
+
|
| 8 |
+
dashboard_bp = Blueprint('dashboard', __name__)
|
| 9 |
+
|
| 10 |
+
@dashboard_bp.route('/')
|
| 11 |
+
@login_required
|
| 12 |
+
def index():
|
| 13 |
+
# Base query
|
| 14 |
+
query = Feedback.query
|
| 15 |
+
|
| 16 |
+
# Filter based on role
|
| 17 |
+
if current_user.role == 'Student' or current_user.role == 'Staff':
|
| 18 |
+
query = query.filter_by(user_id=current_user.id)
|
| 19 |
+
elif current_user.role == 'HOD':
|
| 20 |
+
query = query.filter_by(department_category=current_user.department)
|
| 21 |
+
# Admin sees all (no filter needed)
|
| 22 |
+
|
| 23 |
+
total_count = query.count()
|
| 24 |
+
|
| 25 |
+
# Sentiment distribution
|
| 26 |
+
positive = query.filter_by(sentiment='Positive').count()
|
| 27 |
+
negative = query.filter_by(sentiment='Negative').count()
|
| 28 |
+
neutral = query.filter_by(sentiment='Neutral').count()
|
| 29 |
+
|
| 30 |
+
# Get percentages
|
| 31 |
+
pct_pos = round((positive / total_count * 100) if total_count > 0 else 0, 1)
|
| 32 |
+
pct_neg = round((negative / total_count * 100) if total_count > 0 else 0, 1)
|
| 33 |
+
pct_neu = round((neutral / total_count * 100) if total_count > 0 else 0, 1)
|
| 34 |
+
|
| 35 |
+
# Department distribution
|
| 36 |
+
dept_distribution = dict(query.with_entities(
|
| 37 |
+
Feedback.department_category,
|
| 38 |
+
func.count(Feedback.id)
|
| 39 |
+
).group_by(Feedback.department_category).all())
|
| 40 |
+
|
| 41 |
+
# Get recent feedbacks
|
| 42 |
+
recent_feedback = query.order_by(Feedback.created_at.desc()).limit(10).all()
|
| 43 |
+
|
| 44 |
+
# Pass the appropriate template based on role
|
| 45 |
+
template_name = 'dashboard/student.html'
|
| 46 |
+
if current_user.role == 'Admin':
|
| 47 |
+
template_name = 'dashboard/admin.html'
|
| 48 |
+
elif current_user.role in ['HOD', 'Staff']:
|
| 49 |
+
template_name = 'dashboard/hod_staff.html'
|
| 50 |
+
|
| 51 |
+
return render_template(
|
| 52 |
+
template_name,
|
| 53 |
+
total_count=total_count,
|
| 54 |
+
positive=positive,
|
| 55 |
+
negative=negative,
|
| 56 |
+
neutral=neutral,
|
| 57 |
+
pct_pos=pct_pos,
|
| 58 |
+
pct_neg=pct_neg,
|
| 59 |
+
pct_neu=pct_neu,
|
| 60 |
+
dept_distribution=dept_distribution,
|
| 61 |
+
recent_feedback=recent_feedback
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
@dashboard_bp.route('/api/dashboard_data')
|
| 65 |
+
@login_required
|
| 66 |
+
def get_dashboard_data():
|
| 67 |
+
"""
|
| 68 |
+
AJAX endpoint to fetch filtered dashboard statistics based on date ranges
|
| 69 |
+
without reloading the entire page or restarting Vanta.js
|
| 70 |
+
"""
|
| 71 |
+
from flask import request
|
| 72 |
+
from datetime import datetime
|
| 73 |
+
|
| 74 |
+
start_date_str = request.args.get('start_date')
|
| 75 |
+
end_date_str = request.args.get('end_date')
|
| 76 |
+
|
| 77 |
+
query = Feedback.query
|
| 78 |
+
if current_user.role == 'Student' or current_user.role == 'Staff':
|
| 79 |
+
query = query.filter_by(user_id=current_user.id)
|
| 80 |
+
elif current_user.role == 'HOD':
|
| 81 |
+
query = query.filter_by(department_category=current_user.department)
|
| 82 |
+
|
| 83 |
+
try:
|
| 84 |
+
# Apply date filters if provided
|
| 85 |
+
if start_date_str:
|
| 86 |
+
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
|
| 87 |
+
query = query.filter(Feedback.created_at >= start_date)
|
| 88 |
+
if end_date_str:
|
| 89 |
+
# Add time to include the whole end date
|
| 90 |
+
end_date = datetime.strptime(f"{end_date_str} 23:59:59", '%Y-%m-%d %H:%M:%S')
|
| 91 |
+
query = query.filter(Feedback.created_at <= end_date)
|
| 92 |
+
|
| 93 |
+
total_count = query.count()
|
| 94 |
+
positive = query.filter_by(sentiment='Positive').count()
|
| 95 |
+
negative = query.filter_by(sentiment='Negative').count()
|
| 96 |
+
neutral = query.filter_by(sentiment='Neutral').count()
|
| 97 |
+
|
| 98 |
+
pct_pos = round((positive / total_count * 100) if total_count > 0 else 0, 1)
|
| 99 |
+
pct_neg = round((negative / total_count * 100) if total_count > 0 else 0, 1)
|
| 100 |
+
pct_neu = round((neutral / total_count * 100) if total_count > 0 else 0, 1)
|
| 101 |
+
|
| 102 |
+
dept_raw = query.with_entities(
|
| 103 |
+
Feedback.department_category,
|
| 104 |
+
func.count(Feedback.id)
|
| 105 |
+
).group_by(Feedback.department_category).all()
|
| 106 |
+
|
| 107 |
+
dept_labels = []
|
| 108 |
+
dept_counts = []
|
| 109 |
+
for name, count in dept_raw:
|
| 110 |
+
dept_labels.append(name if name else "Uncategorized")
|
| 111 |
+
dept_counts.append(count)
|
| 112 |
+
|
| 113 |
+
return jsonify({
|
| 114 |
+
'success': True,
|
| 115 |
+
'total_count': total_count,
|
| 116 |
+
'positive': positive, 'negative': negative, 'neutral': neutral,
|
| 117 |
+
'pct_pos': pct_pos, 'pct_neg': pct_neg, 'pct_neu': pct_neu,
|
| 118 |
+
'dept_labels': dept_labels,
|
| 119 |
+
'dept_counts': dept_counts
|
| 120 |
+
})
|
| 121 |
+
except Exception as e:
|
| 122 |
+
print(f"AJAX Data Error: {e}")
|
| 123 |
+
return jsonify({'success': False, 'error': str(e)})
|
| 124 |
+
|
| 125 |
+
@dashboard_bp.route('/api/keywords')
|
| 126 |
+
@login_required
|
| 127 |
+
def get_keywords():
|
| 128 |
+
"""
|
| 129 |
+
Asynchronous endpoint to extract trending keywords from negative feedback
|
| 130 |
+
using Term Frequency-Inverse Document Frequency (TF-IDF).
|
| 131 |
+
This doesn't block the initial page load.
|
| 132 |
+
"""
|
| 133 |
+
# Only analyze negative feedback to find "pain points", or all feedback if preferred.
|
| 134 |
+
# We will analyze all, but you can filter by sentiment='Negative'
|
| 135 |
+
query = Feedback.query
|
| 136 |
+
if current_user.role == 'Student' or current_user.role == 'Staff':
|
| 137 |
+
query = query.filter_by(user_id=current_user.id)
|
| 138 |
+
elif current_user.role == 'HOD':
|
| 139 |
+
query = query.filter_by(department_category=current_user.department)
|
| 140 |
+
|
| 141 |
+
feedbacks = query.all()
|
| 142 |
+
|
| 143 |
+
if len(feedbacks) < 5:
|
| 144 |
+
return jsonify({"keywords": []}) # Not enough data
|
| 145 |
+
|
| 146 |
+
documents = [f.cleaned_text for f in feedbacks if f.cleaned_text]
|
| 147 |
+
|
| 148 |
+
if not documents:
|
| 149 |
+
return jsonify({"keywords": []})
|
| 150 |
+
|
| 151 |
+
try:
|
| 152 |
+
# Extract top 30 meaningful words, ignoring standard english stop words
|
| 153 |
+
vectorizer = TfidfVectorizer(stop_words='english', max_features=30)
|
| 154 |
+
tfidf_matrix = vectorizer.fit_transform(documents)
|
| 155 |
+
|
| 156 |
+
# Sum the TF-IDF scores for each word across all documents
|
| 157 |
+
word_scores = tfidf_matrix.sum(axis=0).A1
|
| 158 |
+
words = vectorizer.get_feature_names_out()
|
| 159 |
+
|
| 160 |
+
# wordcloud2.js expects an array of [word, size] arrays.
|
| 161 |
+
# We multiply the TF-IDF score by a factor (e.g., 20) to make the font size visually impactful
|
| 162 |
+
keywords = []
|
| 163 |
+
for word, score in zip(words, word_scores):
|
| 164 |
+
# Scale score for visual size mapping, ensure minimum size
|
| 165 |
+
size = max(float(score) * 20, 10)
|
| 166 |
+
keywords.append([str(word), size])
|
| 167 |
+
|
| 168 |
+
# Sort by size descending
|
| 169 |
+
keywords.sort(key=lambda x: x[1], reverse=True)
|
| 170 |
+
|
| 171 |
+
return jsonify({"keywords": keywords})
|
| 172 |
+
except Exception as e:
|
| 173 |
+
print(f"Keyword UI error: {e}")
|
| 174 |
+
return jsonify({"keywords": []})
|
routes/feedback.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, request, redirect, url_for, flash, render_template
|
| 2 |
+
from flask_login import login_required, current_user
|
| 3 |
+
from models import db, Feedback
|
| 4 |
+
from utils.nlp_utils import preprocess_text, analyze_sentiment
|
| 5 |
+
|
| 6 |
+
feedback_bp = Blueprint('feedback', __name__)
|
| 7 |
+
|
| 8 |
+
@feedback_bp.route('/submit', methods=['GET', 'POST'])
|
| 9 |
+
@login_required
|
| 10 |
+
def submit():
|
| 11 |
+
if request.method == 'POST':
|
| 12 |
+
original_text = request.form.get('text')
|
| 13 |
+
department = request.form.get('department') or current_user.department
|
| 14 |
+
|
| 15 |
+
if not original_text:
|
| 16 |
+
flash('Comment cannot be empty.', 'danger')
|
| 17 |
+
return redirect(url_for('feedback.submit'))
|
| 18 |
+
|
| 19 |
+
cleaned_text = preprocess_text(original_text)
|
| 20 |
+
sentiment, score = analyze_sentiment(cleaned_text)
|
| 21 |
+
|
| 22 |
+
new_feedback = Feedback(
|
| 23 |
+
user_id=current_user.id,
|
| 24 |
+
original_text=original_text,
|
| 25 |
+
cleaned_text=cleaned_text,
|
| 26 |
+
sentiment=sentiment,
|
| 27 |
+
sentiment_score=score,
|
| 28 |
+
department_category=department
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
db.session.add(new_feedback)
|
| 32 |
+
db.session.commit()
|
| 33 |
+
|
| 34 |
+
flash('Feedback submitted successfully. Thank you!', 'success')
|
| 35 |
+
return redirect(url_for('dashboard.index'))
|
| 36 |
+
|
| 37 |
+
return render_template('feedback/submit.html')
|
routes/profile.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, render_template
|
| 2 |
+
from flask_login import login_required, current_user
|
| 3 |
+
from models import Feedback
|
| 4 |
+
|
| 5 |
+
profile_bp = Blueprint('profile', __name__)
|
| 6 |
+
|
| 7 |
+
@profile_bp.route('/', methods=['GET'])
|
| 8 |
+
@login_required
|
| 9 |
+
def index():
|
| 10 |
+
# Gather statistics for the user
|
| 11 |
+
total_feedbacks = Feedback.query.filter_by(user_id=current_user.id).count()
|
| 12 |
+
|
| 13 |
+
# Calculate their average sentiment score (if they've submitted any)
|
| 14 |
+
avg_score = 0
|
| 15 |
+
if total_feedbacks > 0:
|
| 16 |
+
feedbacks = Feedback.query.filter_by(user_id=current_user.id).all()
|
| 17 |
+
total_score = sum(f.sentiment_score for f in feedbacks)
|
| 18 |
+
avg_score = round(total_score / total_feedbacks, 2)
|
| 19 |
+
|
| 20 |
+
return render_template(
|
| 21 |
+
'dashboard/profile.html',
|
| 22 |
+
total_feedbacks=total_feedbacks,
|
| 23 |
+
avg_score=avg_score
|
| 24 |
+
)
|
routes/reports.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from flask import Blueprint, send_file, current_app, redirect, url_for, flash, make_response
|
| 5 |
+
from flask_login import login_required, current_user
|
| 6 |
+
from models import Feedback
|
| 7 |
+
from utils.decorators import requires_roles
|
| 8 |
+
|
| 9 |
+
# ReportLab imports for PDF generation
|
| 10 |
+
from reportlab.lib.pagesizes import letter
|
| 11 |
+
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
|
| 12 |
+
from reportlab.lib.styles import getSampleStyleSheet
|
| 13 |
+
from reportlab.lib import colors
|
| 14 |
+
|
| 15 |
+
reports_bp = Blueprint('reports', __name__)
|
| 16 |
+
|
| 17 |
+
def get_filtered_query():
|
| 18 |
+
"""Helper to get feedback based on role"""
|
| 19 |
+
query = Feedback.query
|
| 20 |
+
if current_user.role == 'Student' or current_user.role == 'Staff':
|
| 21 |
+
query = query.filter_by(user_id=current_user.id)
|
| 22 |
+
elif current_user.role == 'HOD':
|
| 23 |
+
query = query.filter_by(department_category=current_user.department)
|
| 24 |
+
return query
|
| 25 |
+
|
| 26 |
+
@reports_bp.route('/excel')
|
| 27 |
+
@login_required
|
| 28 |
+
@requires_roles('Admin', 'HOD', 'Staff')
|
| 29 |
+
def download_excel():
|
| 30 |
+
feedbacks = get_filtered_query().all()
|
| 31 |
+
if not feedbacks:
|
| 32 |
+
flash('No data available to generate report.', 'warning')
|
| 33 |
+
return redirect(url_for('dashboard.index'))
|
| 34 |
+
|
| 35 |
+
# Prepare data for pandas
|
| 36 |
+
data = []
|
| 37 |
+
for f in feedbacks:
|
| 38 |
+
data.append({
|
| 39 |
+
'ID': f.id,
|
| 40 |
+
'Date': f.created_at.strftime('%Y-%m-%d %H:%M'),
|
| 41 |
+
'Department/Category': f.department_category,
|
| 42 |
+
'Original Text': f.original_text,
|
| 43 |
+
'Cleaned Text': f.cleaned_text,
|
| 44 |
+
'Sentiment': f.sentiment,
|
| 45 |
+
'Score': f.sentiment_score
|
| 46 |
+
})
|
| 47 |
+
|
| 48 |
+
df = pd.DataFrame(data)
|
| 49 |
+
|
| 50 |
+
# Save to BytesIO
|
| 51 |
+
output = io.BytesIO()
|
| 52 |
+
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
| 53 |
+
df.to_excel(writer, index=False, sheet_name='Sentiment Report')
|
| 54 |
+
|
| 55 |
+
output.seek(0)
|
| 56 |
+
|
| 57 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
| 58 |
+
filename = f"Sentiment_Report_{timestamp}.xlsx"
|
| 59 |
+
|
| 60 |
+
response = make_response(output.getvalue())
|
| 61 |
+
response.headers['Content-Disposition'] = f'attachment; filename={filename}'
|
| 62 |
+
response.headers['Content-Type'] = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
| 63 |
+
return response
|
| 64 |
+
|
| 65 |
+
@reports_bp.route('/pdf')
|
| 66 |
+
@login_required
|
| 67 |
+
@requires_roles('Admin', 'HOD', 'Staff')
|
| 68 |
+
def download_pdf():
|
| 69 |
+
feedbacks = get_filtered_query().order_by(Feedback.created_at.desc()).limit(100).all() # Limit for PDF readability
|
| 70 |
+
if not feedbacks:
|
| 71 |
+
flash('No data available to generate report.', 'warning')
|
| 72 |
+
return redirect(url_for('dashboard.index'))
|
| 73 |
+
|
| 74 |
+
output = io.BytesIO()
|
| 75 |
+
doc = SimpleDocTemplate(output, pagesize=letter)
|
| 76 |
+
elements = []
|
| 77 |
+
|
| 78 |
+
styles = getSampleStyleSheet()
|
| 79 |
+
title_style = styles['Heading1']
|
| 80 |
+
normal_style = styles['Normal']
|
| 81 |
+
|
| 82 |
+
elements.append(Paragraph("AI-Based Sentiment Analysis Report", title_style))
|
| 83 |
+
elements.append(Spacer(1, 12))
|
| 84 |
+
elements.append(Paragraph(f"Generated by: {current_user.name} ({current_user.role})", normal_style))
|
| 85 |
+
elements.append(Paragraph(f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M')}", normal_style))
|
| 86 |
+
elements.append(Spacer(1, 24))
|
| 87 |
+
|
| 88 |
+
# Table data
|
| 89 |
+
data = [['ID', 'Date', 'Sentiment', 'Score', 'Department']]
|
| 90 |
+
for f in feedbacks:
|
| 91 |
+
data.append([
|
| 92 |
+
str(f.id),
|
| 93 |
+
f.created_at.strftime('%Y-%m-%d'),
|
| 94 |
+
f.sentiment,
|
| 95 |
+
f"{f.sentiment_score:.2f}",
|
| 96 |
+
str(f.department_category or 'N/A')
|
| 97 |
+
])
|
| 98 |
+
|
| 99 |
+
# Table styling
|
| 100 |
+
t = Table(data)
|
| 101 |
+
t.setStyle(TableStyle([
|
| 102 |
+
('BACKGROUND', (0,0), (-1,0), colors.grey),
|
| 103 |
+
('TEXTCOLOR', (0,0), (-1,0), colors.whitesmoke),
|
| 104 |
+
('ALIGN', (0,0), (-1,-1), 'CENTER'),
|
| 105 |
+
('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'),
|
| 106 |
+
('BOTTOMPADDING', (0,0), (-1,0), 12),
|
| 107 |
+
('BACKGROUND', (0,1), (-1,-1), colors.beige),
|
| 108 |
+
('GRID', (0,0), (-1,-1), 1, colors.black)
|
| 109 |
+
]))
|
| 110 |
+
|
| 111 |
+
elements.append(t)
|
| 112 |
+
doc.build(elements)
|
| 113 |
+
|
| 114 |
+
output.seek(0)
|
| 115 |
+
|
| 116 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
| 117 |
+
filename = f"Sentiment_Report_{timestamp}.pdf"
|
| 118 |
+
|
| 119 |
+
response = make_response(output.getvalue())
|
| 120 |
+
response.headers['Content-Disposition'] = f'attachment; filename={filename}'
|
| 121 |
+
response.headers['Content-Type'] = 'application/pdf'
|
| 122 |
+
return response
|
routes/training.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import subprocess
|
| 4 |
+
from flask import Blueprint, render_template, jsonify, current_app, flash, redirect, url_for
|
| 5 |
+
from flask_login import login_required, current_user
|
| 6 |
+
|
| 7 |
+
training_bp = Blueprint('training', __name__)
|
| 8 |
+
|
| 9 |
+
STATUS_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "training_status.json")
|
| 10 |
+
TRAIN_SCRIPT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts", "train_model.py")
|
| 11 |
+
CUSTOM_MODEL_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "custom_model")
|
| 12 |
+
|
| 13 |
+
@training_bp.route('/training')
|
| 14 |
+
@login_required
|
| 15 |
+
def index():
|
| 16 |
+
if current_user.role != 'Admin':
|
| 17 |
+
flash('Access denied. Administrator privileges required.', 'danger')
|
| 18 |
+
return redirect(url_for('dashboard.index'))
|
| 19 |
+
|
| 20 |
+
# Check if a custom model already exists
|
| 21 |
+
has_custom = os.path.exists(CUSTOM_MODEL_DIR)
|
| 22 |
+
|
| 23 |
+
return render_template('admin/training.html', has_custom=has_custom)
|
| 24 |
+
|
| 25 |
+
@training_bp.route('/training/start', methods=['POST'])
|
| 26 |
+
@login_required
|
| 27 |
+
def start_training():
|
| 28 |
+
if current_user.role != 'Admin':
|
| 29 |
+
return jsonify({"success": False, "message": "Access denied"}), 403
|
| 30 |
+
|
| 31 |
+
# Reset the status file
|
| 32 |
+
with open(STATUS_FILE, "w") as f:
|
| 33 |
+
json.dump({"status": "Initializing", "progress": 0, "message": "Starting background trainer..."}, f)
|
| 34 |
+
|
| 35 |
+
# Launch training script as an asynchronous subprocess
|
| 36 |
+
try:
|
| 37 |
+
# Use python executable from sys or environment
|
| 38 |
+
subprocess.Popen(['python', TRAIN_SCRIPT],
|
| 39 |
+
stdout=subprocess.DEVNULL,
|
| 40 |
+
stderr=subprocess.DEVNULL)
|
| 41 |
+
return jsonify({"success": True})
|
| 42 |
+
except Exception as e:
|
| 43 |
+
return jsonify({"success": False, "message": str(e)})
|
| 44 |
+
|
| 45 |
+
@training_bp.route('/training/status')
|
| 46 |
+
@login_required
|
| 47 |
+
def check_status():
|
| 48 |
+
if current_user.role != 'Admin':
|
| 49 |
+
return jsonify({"status": "Error", "message": "Access denied"}), 403
|
| 50 |
+
|
| 51 |
+
if not os.path.exists(STATUS_FILE):
|
| 52 |
+
return jsonify({"status": "Idle", "progress": 0, "message": "Waiting to start..."})
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
with open(STATUS_FILE, "r") as f:
|
| 56 |
+
data = json.load(f)
|
| 57 |
+
return jsonify(data)
|
| 58 |
+
except Exception:
|
| 59 |
+
return jsonify({"status": "Error", "progress": 0, "message": "Could not read status file."})
|
routes/upload.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
|
| 3 |
+
from flask_login import login_required, current_user
|
| 4 |
+
from werkzeug.utils import secure_filename
|
| 5 |
+
from models import db, Upload, Feedback
|
| 6 |
+
from utils.file_processor import allowed_file, process_uploaded_file
|
| 7 |
+
from utils.decorators import requires_roles
|
| 8 |
+
|
| 9 |
+
upload_bp = Blueprint('upload', __name__)
|
| 10 |
+
|
| 11 |
+
@upload_bp.route('/', methods=['GET', 'POST'])
|
| 12 |
+
@login_required
|
| 13 |
+
@requires_roles('Admin', 'HOD', 'Staff')
|
| 14 |
+
def index():
|
| 15 |
+
if request.method == 'POST':
|
| 16 |
+
if 'dataset' not in request.files:
|
| 17 |
+
flash('No file part in the request.', 'danger')
|
| 18 |
+
return redirect(request.url)
|
| 19 |
+
|
| 20 |
+
file = request.files['dataset']
|
| 21 |
+
if file.filename == '':
|
| 22 |
+
flash('No selected file.', 'danger')
|
| 23 |
+
return redirect(request.url)
|
| 24 |
+
|
| 25 |
+
if file and allowed_file(file.filename):
|
| 26 |
+
filename = secure_filename(file.filename)
|
| 27 |
+
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
| 28 |
+
file.save(filepath)
|
| 29 |
+
|
| 30 |
+
# Create an upload record
|
| 31 |
+
new_upload = Upload(user_id=current_user.id, filename=filename, status='Processing')
|
| 32 |
+
db.session.add(new_upload)
|
| 33 |
+
db.session.commit()
|
| 34 |
+
|
| 35 |
+
# Process the file (Synchronous for now, ideally background task like Celery)
|
| 36 |
+
selected_column = request.form.get('text_column')
|
| 37 |
+
success, message = process_uploaded_file(filepath, new_upload.id, current_user.id, selected_column)
|
| 38 |
+
|
| 39 |
+
if success:
|
| 40 |
+
flash(message, 'success')
|
| 41 |
+
else:
|
| 42 |
+
flash(f"Error processing file: {message}", 'danger')
|
| 43 |
+
|
| 44 |
+
return redirect(url_for('dashboard.index'))
|
| 45 |
+
else:
|
| 46 |
+
flash('Invalid file type. Allowed: .csv, .xls, .xlsx', 'danger')
|
| 47 |
+
|
| 48 |
+
# GET: View all past uploads
|
| 49 |
+
uploads = Upload.query.order_by(Upload.upload_date.desc()).all()
|
| 50 |
+
return render_template('upload/index.html', uploads=uploads)
|
| 51 |
+
|
| 52 |
+
@upload_bp.route('/delete/<int:upload_id>', methods=['POST'])
|
| 53 |
+
@login_required
|
| 54 |
+
@requires_roles('Admin')
|
| 55 |
+
def delete_upload(upload_id):
|
| 56 |
+
upload = Upload.query.get_or_404(upload_id)
|
| 57 |
+
# The Feedbacks associated will be cascade deleted based on DB schema model
|
| 58 |
+
db.session.delete(upload)
|
| 59 |
+
db.session.commit()
|
| 60 |
+
flash('Upload and associated feedbacks deleted successfully.', 'success')
|
| 61 |
+
return redirect(url_for('upload.index'))
|
scripts/train_model.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import json
|
| 4 |
+
import torch
|
| 5 |
+
from datasets import Dataset
|
| 6 |
+
from transformers import AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments
|
| 7 |
+
# Add root path to access Flask app and db
|
| 8 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 9 |
+
root_dir = os.path.dirname(current_dir)
|
| 10 |
+
sys.path.append(root_dir)
|
| 11 |
+
|
| 12 |
+
from app import create_app
|
| 13 |
+
from models import Feedback
|
| 14 |
+
|
| 15 |
+
MODEL_NAME = "cardiffnlp/twitter-roberta-base-sentiment-latest"
|
| 16 |
+
CUSTOM_MODEL_DIR = os.path.join(root_dir, "custom_model")
|
| 17 |
+
STATUS_FILE = os.path.join(root_dir, "training_status.json")
|
| 18 |
+
|
| 19 |
+
def update_status(status, progress=0, message=""):
|
| 20 |
+
with open(STATUS_FILE, "w") as f:
|
| 21 |
+
json.dump({"status": status, "progress": progress, "message": message}, f)
|
| 22 |
+
|
| 23 |
+
def get_training_data():
|
| 24 |
+
app = create_app()
|
| 25 |
+
with app.app_context():
|
| 26 |
+
# Fetch feedbacks that aren't purely neutral/empty
|
| 27 |
+
feedbacks = Feedback.query.filter(Feedback.sentiment.in_(['Positive', 'Negative'])).all()
|
| 28 |
+
|
| 29 |
+
# Label mapping for CardiffNLP model
|
| 30 |
+
# 0: Negative, 1: Neutral, 2: Positive
|
| 31 |
+
label_map = {'Negative': 0, 'Positive': 2}
|
| 32 |
+
|
| 33 |
+
texts = []
|
| 34 |
+
labels = []
|
| 35 |
+
for f in feedbacks:
|
| 36 |
+
if f.cleaned_text:
|
| 37 |
+
texts.append(f.cleaned_text)
|
| 38 |
+
labels.append(label_map[f.sentiment])
|
| 39 |
+
|
| 40 |
+
return texts, labels
|
| 41 |
+
|
| 42 |
+
def main():
|
| 43 |
+
update_status("Starting", 5, "Extracting data from database...")
|
| 44 |
+
|
| 45 |
+
texts, labels = get_training_data()
|
| 46 |
+
|
| 47 |
+
if len(texts) < 50:
|
| 48 |
+
update_status("Error", 0, "Insufficient data for training. Need at least 50 positive/negative feedback entries.")
|
| 49 |
+
return
|
| 50 |
+
|
| 51 |
+
update_status("Processing", 20, f"Preparing dataset of {len(texts)} entries...")
|
| 52 |
+
|
| 53 |
+
# Load tokenizer
|
| 54 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
|
| 55 |
+
|
| 56 |
+
# Create HuggingFace dataset
|
| 57 |
+
dataset_dict = {
|
| 58 |
+
"text": texts,
|
| 59 |
+
"label": labels
|
| 60 |
+
}
|
| 61 |
+
raw_dataset = Dataset.from_dict(dataset_dict)
|
| 62 |
+
|
| 63 |
+
def tokenize_function(examples):
|
| 64 |
+
return tokenizer(examples["text"], padding="max_length", truncation=True, max_length=128)
|
| 65 |
+
|
| 66 |
+
tokenized_dataset = raw_dataset.map(tokenize_function, batched=True)
|
| 67 |
+
|
| 68 |
+
# Split into train/eval
|
| 69 |
+
split_dataset = tokenized_dataset.train_test_split(test_size=0.1)
|
| 70 |
+
train_dataset = split_dataset["train"]
|
| 71 |
+
eval_dataset = split_dataset["test"]
|
| 72 |
+
|
| 73 |
+
update_status("Training", 40, "Downloading weights and initializing neural network...")
|
| 74 |
+
|
| 75 |
+
# We use num_labels=3 because the base model expects 3
|
| 76 |
+
model = AutoModelForSequenceClassification.from_pretrained(MODEL_NAME, num_labels=3)
|
| 77 |
+
|
| 78 |
+
training_args = TrainingArguments(
|
| 79 |
+
output_dir="./trainer_logs",
|
| 80 |
+
learning_rate=2e-5,
|
| 81 |
+
per_device_train_batch_size=8,
|
| 82 |
+
per_device_eval_batch_size=8,
|
| 83 |
+
num_train_epochs=2,
|
| 84 |
+
weight_decay=0.01,
|
| 85 |
+
evaluation_strategy="epoch",
|
| 86 |
+
save_strategy="epoch",
|
| 87 |
+
load_best_model_at_end=True,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
trainer = Trainer(
|
| 91 |
+
model=model,
|
| 92 |
+
args=training_args,
|
| 93 |
+
train_dataset=train_dataset,
|
| 94 |
+
eval_dataset=eval_dataset,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
update_status("Training", 60, "Fine-tuning model weights... This may take a few minutes.")
|
| 98 |
+
trainer.train()
|
| 99 |
+
|
| 100 |
+
update_status("Saving", 90, "Saving local custom model...")
|
| 101 |
+
# Clean up old directory if exists
|
| 102 |
+
if not os.path.exists(CUSTOM_MODEL_DIR):
|
| 103 |
+
os.makedirs(CUSTOM_MODEL_DIR)
|
| 104 |
+
|
| 105 |
+
model.save_pretrained(CUSTOM_MODEL_DIR)
|
| 106 |
+
tokenizer.save_pretrained(CUSTOM_MODEL_DIR)
|
| 107 |
+
|
| 108 |
+
update_status("Completed", 100, "Successfully trained and exported custom AI model. Application is now using the enhanced AI.")
|
| 109 |
+
|
| 110 |
+
if __name__ == "__main__":
|
| 111 |
+
try:
|
| 112 |
+
main()
|
| 113 |
+
except Exception as e:
|
| 114 |
+
update_status("Error", 0, str(e))
|
sql/schema.sql
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Create Database
|
| 2 |
+
CREATE DATABASE IF NOT EXISTS sentiment_db;
|
| 3 |
+
USE sentiment_db;
|
| 4 |
+
|
| 5 |
+
-- Users Table
|
| 6 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 7 |
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
| 8 |
+
name VARCHAR(100) NOT NULL,
|
| 9 |
+
email VARCHAR(120) NOT NULL UNIQUE,
|
| 10 |
+
password_hash VARCHAR(255) NOT NULL,
|
| 11 |
+
role ENUM('Admin', 'HOD', 'Staff', 'Student') NOT NULL DEFAULT 'Student',
|
| 12 |
+
department VARCHAR(100),
|
| 13 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 14 |
+
);
|
| 15 |
+
|
| 16 |
+
-- Feedback Table (Manual & Extracted)
|
| 17 |
+
CREATE TABLE IF NOT EXISTS feedback (
|
| 18 |
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
| 19 |
+
user_id INT,
|
| 20 |
+
upload_id INT NULL,
|
| 21 |
+
original_text TEXT NOT NULL,
|
| 22 |
+
cleaned_text TEXT,
|
| 23 |
+
sentiment ENUM('Positive', 'Negative', 'Neutral') NOT NULL,
|
| 24 |
+
sentiment_score FLOAT NOT NULL,
|
| 25 |
+
department_category VARCHAR(100),
|
| 26 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 27 |
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
| 28 |
+
);
|
| 29 |
+
|
| 30 |
+
-- Uploads Table (Tracking Bulk Uploads)
|
| 31 |
+
CREATE TABLE IF NOT EXISTS uploads (
|
| 32 |
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
| 33 |
+
user_id INT,
|
| 34 |
+
filename VARCHAR(255) NOT NULL,
|
| 35 |
+
total_rows INT DEFAULT 0,
|
| 36 |
+
processed_rows INT DEFAULT 0,
|
| 37 |
+
status ENUM('Pending', 'Processing', 'Completed', 'Failed') DEFAULT 'Pending',
|
| 38 |
+
upload_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 39 |
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
| 40 |
+
);
|
| 41 |
+
|
| 42 |
+
-- Add foreign key back to feedback
|
| 43 |
+
ALTER TABLE feedback
|
| 44 |
+
ADD CONSTRAINT fk_upload_id
|
| 45 |
+
FOREIGN KEY (upload_id) REFERENCES uploads(id) ON DELETE CASCADE;
|
| 46 |
+
|
| 47 |
+
-- Insert default Admin User (password: admin123)
|
| 48 |
+
-- Hash generated by bcrypt in Python: bcrypt.hashpw(b'admin123', bcrypt.gensalt())
|
| 49 |
+
-- Note: Replace with actual hash below or let application handle first startup
|
| 50 |
+
INSERT INTO users (name, email, password_hash, role, department)
|
| 51 |
+
VALUES ('System Admin', 'admin@example.com', '$2b$12$K8Z7J9vJ.5u9wD9/xS9T.O4a1xY7r/VjN6B2/5g3e4lq5vM1.bBki', 'Admin', 'Management')
|
| 52 |
+
ON DUPLICATE KEY UPDATE name='System Admin';
|
static/css/style.css
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap');
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--bg-gradient-light: linear-gradient(135deg, #f0f4f8 0%, #e0e7ff 100%);
|
| 5 |
+
--bg-gradient-dark: linear-gradient(135deg, #090e17 0%, #151b29 100%);
|
| 6 |
+
--glass-bg-light: rgba(255, 255, 255, 0.7);
|
| 7 |
+
--glass-border-light: rgba(255, 255, 255, 0.4);
|
| 8 |
+
--glass-bg-dark: rgba(21, 27, 41, 0.65);
|
| 9 |
+
--glass-border-dark: rgba(255, 255, 255, 0.08);
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
body {
|
| 13 |
+
font-family: 'Outfit', sans-serif;
|
| 14 |
+
transition: background-color 0.4s ease-in-out, color 0.4s ease-in-out;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
body.light {
|
| 18 |
+
background: var(--bg-gradient-light);
|
| 19 |
+
min-height: 100vh;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
body.dark {
|
| 23 |
+
background: var(--bg-gradient-dark);
|
| 24 |
+
min-height: 100vh;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/* Glassmorphism Utilities */
|
| 28 |
+
.glass {
|
| 29 |
+
background: var(--glass-bg-light);
|
| 30 |
+
backdrop-filter: blur(12px);
|
| 31 |
+
-webkit-backdrop-filter: blur(12px);
|
| 32 |
+
border: 1px solid var(--glass-border-light);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.dark .glass {
|
| 36 |
+
background: var(--glass-bg-dark);
|
| 37 |
+
border: 1px solid var(--glass-border-dark);
|
| 38 |
+
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.3);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/* Sidebar Custom Scrollbar */
|
| 42 |
+
.sidebar::-webkit-scrollbar {
|
| 43 |
+
width: 5px;
|
| 44 |
+
}
|
| 45 |
+
.sidebar::-webkit-scrollbar-track {
|
| 46 |
+
background: transparent;
|
| 47 |
+
}
|
| 48 |
+
.sidebar::-webkit-scrollbar-thumb {
|
| 49 |
+
background-color: rgba(156, 163, 175, 0.4);
|
| 50 |
+
border-radius: 10px;
|
| 51 |
+
}
|
| 52 |
+
.sidebar::-webkit-scrollbar-thumb:hover {
|
| 53 |
+
background-color: rgba(156, 163, 175, 0.7);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/* Card Hover Animations & Glow */
|
| 57 |
+
.hover-card {
|
| 58 |
+
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
| 59 |
+
}
|
| 60 |
+
.hover-card:hover {
|
| 61 |
+
transform: translateY(-6px) scale(1.01);
|
| 62 |
+
box-shadow: 0 20px 30px -10px rgba(59, 130, 246, 0.15), 0 10px 15px -5px rgba(0, 0, 0, 0.05);
|
| 63 |
+
border-color: rgba(59, 130, 246, 0.3);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.dark .hover-card:hover {
|
| 67 |
+
box-shadow: 0 20px 30px -10px rgba(99, 102, 241, 0.2), 0 10px 15px -5px rgba(0, 0, 0, 0.3);
|
| 68 |
+
border-color: rgba(99, 102, 241, 0.4);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/* Vibrant Gradient Text */
|
| 72 |
+
.text-gradient {
|
| 73 |
+
background-clip: text;
|
| 74 |
+
-webkit-background-clip: text;
|
| 75 |
+
-webkit-text-fill-color: transparent;
|
| 76 |
+
background-size: 200% auto;
|
| 77 |
+
background-image: linear-gradient(to right, #4facfe 0%, #00f2fe 50%, #4facfe 100%);
|
| 78 |
+
animation: shine 4s linear infinite;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.dark .text-gradient {
|
| 82 |
+
background-image: linear-gradient(to right, #a8c0ff 0%, #3f2b96 50%, #a8c0ff 100%);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
@keyframes shine {
|
| 86 |
+
to {
|
| 87 |
+
background-position: 200% center;
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/* Primary Button Glow */
|
| 92 |
+
.btn-primary {
|
| 93 |
+
background-image: linear-gradient(to right, #3b82f6, #6366f1);
|
| 94 |
+
color: white;
|
| 95 |
+
border-radius: 0.5rem;
|
| 96 |
+
transition: all 0.3s ease;
|
| 97 |
+
position: relative;
|
| 98 |
+
overflow: hidden;
|
| 99 |
+
z-index: 1;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.btn-primary::before {
|
| 103 |
+
content: '';
|
| 104 |
+
position: absolute;
|
| 105 |
+
top: 0; left: 0; right: 0; bottom: 0;
|
| 106 |
+
background-image: linear-gradient(to right, #2563eb, #4f46e5);
|
| 107 |
+
z-index: -1;
|
| 108 |
+
transition: opacity 0.3s ease;
|
| 109 |
+
opacity: 0;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.btn-primary:hover::before {
|
| 113 |
+
opacity: 1;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.btn-primary:hover {
|
| 117 |
+
transform: translateY(-2px);
|
| 118 |
+
box-shadow: 0 10px 20px -5px rgba(99, 102, 241, 0.4);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
/* Form Input Animations */
|
| 122 |
+
.form-input {
|
| 123 |
+
transition: all 0.3s ease;
|
| 124 |
+
background-color: var(--glass-bg-light);
|
| 125 |
+
border: 1px solid var(--glass-border-light);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.form-input:focus {
|
| 129 |
+
transform: scale(1.02);
|
| 130 |
+
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.dark .form-input {
|
| 134 |
+
background-color: rgba(31, 41, 55, 0.5);
|
| 135 |
+
border-color: rgba(75, 85, 99, 0.4);
|
| 136 |
+
color: white;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.dark .form-input:focus {
|
| 140 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.3);
|
| 141 |
+
border-color: rgba(99, 102, 241, 0.5);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
/* Standardized Alerts */
|
| 145 |
+
.alert-success { background-color: rgba(209, 250, 229, 0.8); color: #065f46; border-left: 4px solid #10b981; backdrop-filter: blur(4px); }
|
| 146 |
+
.alert-danger { background-color: rgba(254, 226, 226, 0.8); color: #991b1b; border-left: 4px solid #ef4444; backdrop-filter: blur(4px); }
|
| 147 |
+
.alert-warning { background-color: rgba(254, 243, 199, 0.8); color: #92400e; border-left: 4px solid #f59e0b; backdrop-filter: blur(4px); }
|
| 148 |
+
.alert-info { background-color: rgba(219, 234, 254, 0.8); color: #1e40af; border-left: 4px solid #3b82f6; backdrop-filter: blur(4px); }
|
| 149 |
+
|
| 150 |
+
.dark .alert-success { background-color: rgba(6, 95, 70, 0.4); color: #34d399; border-left-color: #059669; }
|
| 151 |
+
.dark .alert-danger { background-color: rgba(153, 27, 27, 0.4); color: #f87171; border-left-color: #dc2626; }
|
| 152 |
+
.dark .alert-warning { background-color: rgba(146, 64, 14, 0.4); color: #fbbf24; border-left-color: #d97706; }
|
| 153 |
+
.dark .alert-info { background-color: rgba(30, 64, 175, 0.4); color: #60a5fa; border-left-color: #2563eb; }
|
| 154 |
+
|
| 155 |
+
/* Micro-animations */
|
| 156 |
+
.animate-fade-in {
|
| 157 |
+
animation: fadeIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
@keyframes fadeIn {
|
| 161 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 162 |
+
to { opacity: 1; transform: translateY(0); }
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.animation-delay-100 { animation-delay: 100ms; }
|
| 166 |
+
.animation-delay-200 { animation-delay: 200ms; }
|
| 167 |
+
.animation-delay-300 { animation-delay: 300ms; }
|
static/js/charts.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const chartConfig = {
|
| 2 |
+
// Colors matching our Tailwind theme with vibrant gradient-like values
|
| 3 |
+
colors: {
|
| 4 |
+
positive: '#10b981', // Emerald-500
|
| 5 |
+
negative: '#f43f5e', // Rose-500
|
| 6 |
+
neutral: '#8b5cf6', // Violet-500
|
| 7 |
+
primary: '#3b82f6', // Blue-500
|
| 8 |
+
purple: '#a855f7', // Purple-500
|
| 9 |
+
bgPositive: 'rgba(16, 185, 129, 0.15)',
|
| 10 |
+
bgNegative: 'rgba(244, 63, 94, 0.15)',
|
| 11 |
+
bgNeutral: 'rgba(139, 92, 246, 0.15)',
|
| 12 |
+
bgPrimary: 'rgba(59, 130, 246, 0.15)',
|
| 13 |
+
},
|
| 14 |
+
textColor: function() {
|
| 15 |
+
return document.documentElement.classList.contains('dark') ? '#e5e7eb' : '#374151';
|
| 16 |
+
},
|
| 17 |
+
gridColor: function() {
|
| 18 |
+
return document.documentElement.classList.contains('dark') ? '#374151' : '#e5e7eb';
|
| 19 |
+
}
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
function renderSentimentPieChart(ctxId, data) {
|
| 23 |
+
const ctx = document.getElementById(ctxId);
|
| 24 |
+
if (!ctx) return;
|
| 25 |
+
|
| 26 |
+
new Chart(ctx, {
|
| 27 |
+
type: 'doughnut',
|
| 28 |
+
data: {
|
| 29 |
+
labels: ['Positive', 'Negative', 'Neutral'],
|
| 30 |
+
datasets: [{
|
| 31 |
+
data: [data.positive, data.negative, data.neutral],
|
| 32 |
+
backgroundColor: [
|
| 33 |
+
chartConfig.colors.positive,
|
| 34 |
+
chartConfig.colors.negative,
|
| 35 |
+
chartConfig.colors.neutral
|
| 36 |
+
],
|
| 37 |
+
borderWidth: 0,
|
| 38 |
+
hoverOffset: 12
|
| 39 |
+
}]
|
| 40 |
+
},
|
| 41 |
+
options: {
|
| 42 |
+
responsive: true,
|
| 43 |
+
maintainAspectRatio: false,
|
| 44 |
+
layout: {
|
| 45 |
+
padding: 10
|
| 46 |
+
},
|
| 47 |
+
plugins: {
|
| 48 |
+
legend: {
|
| 49 |
+
position: 'bottom',
|
| 50 |
+
labels: {
|
| 51 |
+
color: chartConfig.textColor(),
|
| 52 |
+
padding: 20,
|
| 53 |
+
usePointStyle: true,
|
| 54 |
+
pointStyle: 'circle'
|
| 55 |
+
}
|
| 56 |
+
},
|
| 57 |
+
tooltip: {
|
| 58 |
+
backgroundColor: 'rgba(15, 23, 42, 0.9)',
|
| 59 |
+
titleColor: '#fff',
|
| 60 |
+
bodyColor: '#cbd5e1',
|
| 61 |
+
borderColor: 'rgba(255, 255, 255, 0.1)',
|
| 62 |
+
borderWidth: 1,
|
| 63 |
+
padding: 12,
|
| 64 |
+
displayColors: true,
|
| 65 |
+
boxPadding: 6
|
| 66 |
+
}
|
| 67 |
+
},
|
| 68 |
+
cutout: '75%',
|
| 69 |
+
animation: { animateScale: true, animateRotate: true, duration: 1500, easing: 'easeOutQuart' }
|
| 70 |
+
}
|
| 71 |
+
});
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function renderDepartmentBarChart(ctxId, labels, data) {
|
| 75 |
+
const ctx = document.getElementById(ctxId);
|
| 76 |
+
if (!ctx) return;
|
| 77 |
+
|
| 78 |
+
new Chart(ctx, {
|
| 79 |
+
type: 'bar',
|
| 80 |
+
data: {
|
| 81 |
+
labels: labels,
|
| 82 |
+
datasets: [{
|
| 83 |
+
label: 'Feedback Count',
|
| 84 |
+
data: data,
|
| 85 |
+
backgroundColor: chartConfig.colors.bgPrimary,
|
| 86 |
+
borderColor: chartConfig.colors.primary,
|
| 87 |
+
borderWidth: 2,
|
| 88 |
+
borderRadius: 6,
|
| 89 |
+
hoverBackgroundColor: chartConfig.colors.primary,
|
| 90 |
+
hoverBorderColor: '#60a5fa'
|
| 91 |
+
}]
|
| 92 |
+
},
|
| 93 |
+
options: {
|
| 94 |
+
responsive: true,
|
| 95 |
+
maintainAspectRatio: false,
|
| 96 |
+
layout: {
|
| 97 |
+
padding: { top: 10, right: 10, bottom: 0, left: 0 }
|
| 98 |
+
},
|
| 99 |
+
plugins: {
|
| 100 |
+
legend: { display: false },
|
| 101 |
+
tooltip: {
|
| 102 |
+
backgroundColor: 'rgba(15, 23, 42, 0.9)',
|
| 103 |
+
titleColor: '#fff',
|
| 104 |
+
bodyColor: '#cbd5e1',
|
| 105 |
+
borderColor: 'rgba(255, 255, 255, 0.1)',
|
| 106 |
+
borderWidth: 1,
|
| 107 |
+
padding: 12,
|
| 108 |
+
displayColors: false,
|
| 109 |
+
cornerRadius: 8
|
| 110 |
+
}
|
| 111 |
+
},
|
| 112 |
+
scales: {
|
| 113 |
+
y: {
|
| 114 |
+
beginAtZero: true,
|
| 115 |
+
ticks: { color: chartConfig.textColor(), precision: 0, padding: 10 },
|
| 116 |
+
grid: { color: chartConfig.gridColor(), drawBorder: false, borderDash: [5, 5] },
|
| 117 |
+
border: { display: false }
|
| 118 |
+
},
|
| 119 |
+
x: {
|
| 120 |
+
ticks: { color: chartConfig.textColor(), padding: 10 },
|
| 121 |
+
grid: { display: false },
|
| 122 |
+
border: { display: false }
|
| 123 |
+
}
|
| 124 |
+
},
|
| 125 |
+
animation: {
|
| 126 |
+
duration: 1200,
|
| 127 |
+
easing: 'easeOutQuart'
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
});
|
| 131 |
+
});
|
| 132 |
+
}
|
| 133 |
+
// Re-render charts on theme change to update text colors
|
| 134 |
+
document.getElementById('theme-toggle')?.addEventListener('click', () => {
|
| 135 |
+
// A quick hack is just to reload the page or we could store chart instances and update them.
|
| 136 |
+
// For simplicity, we delay and let the CSS transition finish, though proper way is updating chart config.
|
| 137 |
+
setTimeout(() => {
|
| 138 |
+
window.dispatchEvent(new Event('resize'));
|
| 139 |
+
// If we want actual color updates without reload, we need to loop Chart.instances
|
| 140 |
+
for (let id in Chart.instances) {
|
| 141 |
+
let chart = Chart.instances[id];
|
| 142 |
+
|
| 143 |
+
// Update Pie border color
|
| 144 |
+
if (chart.config.type === 'doughnut') {
|
| 145 |
+
chart.data.datasets[0].borderColor = document.documentElement.classList.contains('dark') ? '#1f2937' : '#ffffff';
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// Update texts
|
| 149 |
+
if (chart.options.plugins?.legend?.labels) {
|
| 150 |
+
chart.options.plugins.legend.labels.color = chartConfig.textColor();
|
| 151 |
+
}
|
| 152 |
+
if (chart.options.scales?.y?.ticks) {
|
| 153 |
+
chart.options.scales.y.ticks.color = chartConfig.textColor();
|
| 154 |
+
chart.options.scales.x.ticks.color = chartConfig.textColor();
|
| 155 |
+
chart.options.scales.y.grid.color = chartConfig.gridColor();
|
| 156 |
+
}
|
| 157 |
+
chart.update();
|
| 158 |
+
}
|
| 159 |
+
}, 100);
|
| 160 |
+
});
|
static/js/main.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Dark Mode Toggle Logic
|
| 2 |
+
const themeToggleBtn = document.getElementById('theme-toggle');
|
| 3 |
+
const darkIcon = document.getElementById('theme-toggle-dark-icon');
|
| 4 |
+
const lightIcon = document.getElementById('theme-toggle-light-icon');
|
| 5 |
+
|
| 6 |
+
// Change the icons inside the button based on previous settings
|
| 7 |
+
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
| 8 |
+
darkIcon?.classList.add('hidden');
|
| 9 |
+
lightIcon?.classList.remove('hidden');
|
| 10 |
+
document.documentElement.classList.add('dark');
|
| 11 |
+
} else {
|
| 12 |
+
lightIcon?.classList.add('hidden');
|
| 13 |
+
darkIcon?.classList.remove('hidden');
|
| 14 |
+
document.documentElement.classList.remove('dark');
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
if (themeToggleBtn) {
|
| 18 |
+
themeToggleBtn.addEventListener('click', function() {
|
| 19 |
+
// toggle icons inside button
|
| 20 |
+
darkIcon.classList.toggle('hidden');
|
| 21 |
+
lightIcon.classList.toggle('hidden');
|
| 22 |
+
|
| 23 |
+
// if set via local storage previously
|
| 24 |
+
if (localStorage.getItem('color-theme')) {
|
| 25 |
+
if (localStorage.getItem('color-theme') === 'light') {
|
| 26 |
+
document.documentElement.classList.add('dark');
|
| 27 |
+
localStorage.setItem('color-theme', 'dark');
|
| 28 |
+
} else {
|
| 29 |
+
document.documentElement.classList.remove('dark');
|
| 30 |
+
localStorage.setItem('color-theme', 'light');
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// if NOT set via local storage previously
|
| 34 |
+
} else {
|
| 35 |
+
if (document.documentElement.classList.contains('dark')) {
|
| 36 |
+
document.documentElement.classList.remove('dark');
|
| 37 |
+
localStorage.setItem('color-theme', 'light');
|
| 38 |
+
} else {
|
| 39 |
+
document.documentElement.classList.add('dark');
|
| 40 |
+
localStorage.setItem('color-theme', 'dark');
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
});
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// Sidebar Toggle Mobile
|
| 47 |
+
const sidebarToggles = document.querySelectorAll('.sidebar-toggle-btn');
|
| 48 |
+
const sidebar = document.getElementById('sidebar');
|
| 49 |
+
|
| 50 |
+
if (sidebar) {
|
| 51 |
+
sidebarToggles.forEach(btn => {
|
| 52 |
+
btn.addEventListener('click', () => {
|
| 53 |
+
sidebar.classList.toggle('-translate-x-full');
|
| 54 |
+
});
|
| 55 |
+
});
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// Fade out alerts
|
| 59 |
+
setTimeout(() => {
|
| 60 |
+
const alerts = document.querySelectorAll('.alert-auto-dismiss');
|
| 61 |
+
alerts.forEach(alert => {
|
| 62 |
+
alert.style.transition = "opacity 0.5s ease";
|
| 63 |
+
alert.style.opacity = "0";
|
| 64 |
+
setTimeout(() => alert.remove(), 500);
|
| 65 |
+
});
|
| 66 |
+
}, 5000);
|
templates/admin/create_user.html
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Create User - NeuroSent Admin{% endblock %}
|
| 3 |
+
|
| 4 |
+
{% block extra_head %}
|
| 5 |
+
<!-- Page specific head elements -->
|
| 6 |
+
{% endblock %}
|
| 7 |
+
|
| 8 |
+
{% block content %}
|
| 9 |
+
<!-- Vanta.js Background Container. Use absolute positioning to fill screen without disrupting flow -->
|
| 10 |
+
<div id="vanta-bg" class="fixed inset-0 z-0 pointer-events-none opacity-40 dark:opacity-30 mix-blend-screen transition-opacity duration-1000"></div>
|
| 11 |
+
|
| 12 |
+
<div class="relative z-10 max-w-2xl mx-auto pt-4 animate-fade-in">
|
| 13 |
+
<div class="mb-8 text-center">
|
| 14 |
+
<h1 class="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-indigo-600 dark:from-primary-400 dark:to-indigo-400 tracking-tight drop-shadow-sm">System Administration</h1>
|
| 15 |
+
<p class="text-gray-600 dark:text-gray-300 font-medium text-sm mt-2">Provision new staff, faculty, or student accounts securely.</p>
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
<!-- Stunning Glass Card over 3D background -->
|
| 19 |
+
<div class="glass rounded-3xl shadow-2xl p-8 sm:p-10 border border-white/40 dark:border-gray-700/60 backdrop-blur-2xl relative overflow-hidden transition-all hover:shadow-[0_20px_60px_-15px_rgba(0,0,0,0.3)]">
|
| 20 |
+
|
| 21 |
+
<!-- Subtle internal glowing orb -->
|
| 22 |
+
<div class="absolute -top-20 -right-20 w-56 h-56 bg-primary-400/20 dark:bg-primary-500/10 rounded-full blur-3xl pointer-events-none"></div>
|
| 23 |
+
|
| 24 |
+
<form method="POST" action="{{ url_for('admin.create_user') }}" class="relative z-10 space-y-6">
|
| 25 |
+
|
| 26 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 27 |
+
<div class="space-y-2">
|
| 28 |
+
<label for="name" class="block text-sm font-bold text-gray-800 dark:text-gray-200 uppercase tracking-wide">Full Name</label>
|
| 29 |
+
<div class="relative">
|
| 30 |
+
<div class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none text-primary-500">
|
| 31 |
+
<i class="fa-solid fa-id-badge"></i>
|
| 32 |
+
</div>
|
| 33 |
+
<input type="text" name="name" id="name" class="bg-white/70 border border-gray-300/50 text-gray-900 rounded-xl focus:ring-primary-500 focus:border-primary-500 block w-full pl-11 p-3.5 dark:bg-gray-800/70 dark:border-gray-600/50 dark:placeholder-gray-400 dark:text-white shadow-inner transition-all focus:bg-white dark:focus:bg-gray-900" placeholder="Jane Doe" required>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<div class="space-y-2">
|
| 38 |
+
<label for="email" class="block text-sm font-bold text-gray-800 dark:text-gray-200 uppercase tracking-wide">Email Address</label>
|
| 39 |
+
<div class="relative">
|
| 40 |
+
<div class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none text-primary-500">
|
| 41 |
+
<i class="fa-solid fa-at"></i>
|
| 42 |
+
</div>
|
| 43 |
+
<input type="email" name="email" id="email" class="bg-white/70 border border-gray-300/50 text-gray-900 rounded-xl focus:ring-primary-500 focus:border-primary-500 block w-full pl-11 p-3.5 dark:bg-gray-800/70 dark:border-gray-600/50 dark:placeholder-gray-400 dark:text-white shadow-inner transition-all focus:bg-white dark:focus:bg-gray-900" placeholder="jane@institution.edu" required>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 49 |
+
<div class="space-y-2">
|
| 50 |
+
<label for="password" class="block text-sm font-bold text-gray-800 dark:text-gray-200 uppercase tracking-wide">Secure Password</label>
|
| 51 |
+
<div class="relative">
|
| 52 |
+
<div class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none text-primary-500">
|
| 53 |
+
<i class="fa-solid fa-key"></i>
|
| 54 |
+
</div>
|
| 55 |
+
<input type="text" name="password" id="password" class="bg-white/70 border border-gray-300/50 text-gray-900 rounded-xl focus:ring-primary-500 focus:border-primary-500 block w-full pl-11 p-3.5 dark:bg-gray-800/70 dark:border-gray-600/50 dark:placeholder-gray-400 dark:text-white shadow-inner transition-all focus:bg-white dark:focus:bg-gray-900 font-mono tracking-widest" value="{{ range(10000000, 99999999) | random | string }}" required>
|
| 56 |
+
</div>
|
| 57 |
+
<p class="text-xs text-gray-500 dark:text-gray-400 italic">Auto-generated password provided. Edit if necessary.</p>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<div class="space-y-2">
|
| 61 |
+
<label for="role" class="block text-sm font-bold text-gray-800 dark:text-gray-200 uppercase tracking-wide">Account Role</label>
|
| 62 |
+
<div class="relative">
|
| 63 |
+
<div class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none text-primary-500 z-10">
|
| 64 |
+
<i class="fa-solid fa-user-shield"></i>
|
| 65 |
+
</div>
|
| 66 |
+
<select name="role" id="role" class="bg-white/70 border border-gray-300/50 text-gray-900 rounded-xl focus:ring-primary-500 focus:border-primary-500 block w-full pl-11 p-3.5 dark:bg-gray-800/70 dark:border-gray-600/50 dark:placeholder-gray-400 dark:text-white shadow-inner transition-all focus:bg-white dark:focus:bg-gray-900 appearance-none relative pr-10 cursor-pointer" required>
|
| 67 |
+
<option value="Student">Student (Default)</option>
|
| 68 |
+
<option value="Staff">Staff</option>
|
| 69 |
+
<option value="HOD">Head of Department (HOD)</option>
|
| 70 |
+
<option value="Admin">Administrator</option>
|
| 71 |
+
</select>
|
| 72 |
+
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-4 text-gray-500 dark:text-gray-400">
|
| 73 |
+
<i class="fa-solid fa-chevron-down text-sm"></i>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<div class="space-y-2 pt-2">
|
| 80 |
+
<label for="department" class="block text-sm font-bold text-gray-800 dark:text-gray-200 uppercase tracking-wide">Assigned Department <span class="text-gray-500 dark:text-gray-400 font-normal lowercase">(Optional for Students)</span></label>
|
| 81 |
+
<div class="relative">
|
| 82 |
+
<div class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none text-primary-500">
|
| 83 |
+
<i class="fa-solid fa-building-user"></i>
|
| 84 |
+
</div>
|
| 85 |
+
<input type="text" name="department" id="department" class="bg-white/70 border border-gray-300/50 text-gray-900 rounded-xl focus:ring-primary-500 focus:border-primary-500 block w-full pl-11 p-3.5 dark:bg-gray-800/70 dark:border-gray-600/50 dark:placeholder-gray-400 dark:text-white shadow-inner transition-all focus:bg-white dark:focus:bg-gray-900" placeholder="e.g. Computer Science, Human Resources">
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
<div class="pt-6">
|
| 90 |
+
<button type="submit" class="w-full text-white bg-gradient-to-r from-primary-600 to-indigo-600 hover:from-primary-700 hover:to-indigo-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-extrabold text-base rounded-xl px-5 py-4 text-center dark:focus:ring-primary-800 shadow-xl hover:shadow-2xl transform transition-all hover:-translate-y-1 flex justify-center items-center gap-3 border border-white/20">
|
| 91 |
+
<i class="fa-solid fa-user-plus text-xl"></i> Create Account
|
| 92 |
+
</button>
|
| 93 |
+
</div>
|
| 94 |
+
</form>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
{% endblock %}
|
| 98 |
+
|
| 99 |
+
{% block extra_scripts %}
|
| 100 |
+
<!-- Local scripts for Admin Create User page can go here ->
|
| 101 |
+
{% endblock %}
|
templates/admin/manage_users.html
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Manage Users - NeuroSent Admin{% endblock %}
|
| 3 |
+
|
| 4 |
+
{% block content %}
|
| 5 |
+
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.min.css">
|
| 6 |
+
<style>
|
| 7 |
+
/* Glassmorphism overrides for DataTables */
|
| 8 |
+
.dataTables_wrapper .dataTables_length, .dataTables_wrapper .dataTables_filter, .dataTables_wrapper .dataTables_info, .dataTables_wrapper .dataTables_processing, .dataTables_wrapper .dataTables_paginate {
|
| 9 |
+
color: inherit;
|
| 10 |
+
padding: 1rem;
|
| 11 |
+
}
|
| 12 |
+
.dataTables_wrapper .dataTables_paginate .paginate_button {
|
| 13 |
+
border-radius: 0.5rem;
|
| 14 |
+
color: inherit !important;
|
| 15 |
+
}
|
| 16 |
+
.dataTables_wrapper .dataTables_paginate .paginate_button.current, .dataTables_wrapper .dataTables_paginate .paginate_button.current:hover {
|
| 17 |
+
background: rgba(99, 102, 241, 0.2);
|
| 18 |
+
border: 1px solid rgba(99, 102, 241, 0.5);
|
| 19 |
+
color: inherit !important;
|
| 20 |
+
}
|
| 21 |
+
</style>
|
| 22 |
+
<div class="relative z-10 max-w-6xl mx-auto pt-4 animate-fade-in">
|
| 23 |
+
<div class="mb-8 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
| 24 |
+
<div>
|
| 25 |
+
<h1 class="text-3xl sm:text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-indigo-600 dark:from-primary-400 dark:to-indigo-400 tracking-tight drop-shadow-sm">Manage Personnel</h1>
|
| 26 |
+
<p class="text-gray-600 dark:text-gray-300 font-medium text-sm mt-2">View, reset passwords, or delete registered user accounts.</p>
|
| 27 |
+
</div>
|
| 28 |
+
<a href="{{ url_for('admin.create_user') }}" class="inline-flex items-center px-4 py-2 bg-gradient-to-r from-primary-600 to-indigo-600 hover:from-primary-700 hover:to-indigo-700 text-white text-sm font-bold rounded-xl shadow-lg hover:shadow-xl transition-all hover:-translate-y-0.5">
|
| 29 |
+
<i class="fa-solid fa-plus mr-2"></i> Add New User
|
| 30 |
+
</a>
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
<!-- Stunning Glass Container -->
|
| 34 |
+
<div class="glass rounded-3xl shadow-xl border border-white/40 dark:border-gray-700/60 backdrop-blur-2xl relative overflow-hidden transition-all">
|
| 35 |
+
<!-- Subtle internal glowing orb -->
|
| 36 |
+
<div class="absolute -bottom-20 -left-20 w-64 h-64 bg-primary-400/20 dark:bg-primary-500/10 rounded-full blur-3xl pointer-events-none"></div>
|
| 37 |
+
|
| 38 |
+
<div class="w-full relative z-10">
|
| 39 |
+
<table id="usersTable" class="w-full text-sm text-left text-gray-700 dark:text-gray-300">
|
| 40 |
+
<thead class="text-xs text-gray-800 uppercase bg-primary-50/50 dark:bg-gray-800/80 dark:text-gray-300 border-b border-gray-200/50 dark:border-gray-700/50 backdrop-blur-md">
|
| 41 |
+
<tr>
|
| 42 |
+
<th scope="col" class="px-6 py-4 font-bold tracking-wider">User</th>
|
| 43 |
+
<th scope="col" class="px-6 py-4 font-bold tracking-wider">Role</th>
|
| 44 |
+
<th scope="col" class="px-6 py-4 font-bold tracking-wider">Department</th>
|
| 45 |
+
<th scope="col" class="px-6 py-4 font-bold tracking-wider">Status</th>
|
| 46 |
+
<th scope="col" class="px-6 py-4 font-bold tracking-wider text-right">Actions</th>
|
| 47 |
+
</tr>
|
| 48 |
+
</thead>
|
| 49 |
+
<tbody class="divide-y divide-gray-200/50 dark:divide-gray-700/50">
|
| 50 |
+
{% for user in users %}
|
| 51 |
+
<tr class="bg-white/40 dark:bg-gray-800/30 hover:bg-white/60 dark:hover:bg-gray-800/60 transition-colors">
|
| 52 |
+
<td class="px-6 py-4 whitespace-nowrap">
|
| 53 |
+
<div class="flex items-center">
|
| 54 |
+
<div class="flex-shrink-0 h-10 w-10 flex items-center justify-center rounded-full bg-gradient-to-br from-blue-100 to-indigo-100 dark:from-blue-900/40 dark:to-indigo-900/40 text-primary-600 dark:text-primary-400 font-bold border border-white/50 dark:border-gray-700 shadow-inner">
|
| 55 |
+
{{ user.name[0] | upper }}
|
| 56 |
+
</div>
|
| 57 |
+
<div class="ml-4">
|
| 58 |
+
<div class="text-sm font-bold text-gray-900 dark:text-white">{{ user.name }}</div>
|
| 59 |
+
<div class="text-xs text-gray-500 dark:text-gray-400 font-medium">{{ user.email }}</div>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
</td>
|
| 63 |
+
<td class="px-6 py-4 whitespace-nowrap">
|
| 64 |
+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-bold {% if user.role == 'Admin' %}bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 border border-purple-200 dark:border-purple-800/50{% elif user.role == 'HOD' %}bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300 border border-orange-200 dark:border-orange-800/50{% elif user.role == 'Staff' %}bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 border border-blue-200 dark:border-blue-800/50{% else %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 border border-green-200 dark:border-green-800/50{% endif %}">
|
| 65 |
+
{{ user.role }}
|
| 66 |
+
</span>
|
| 67 |
+
</td>
|
| 68 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
| 69 |
+
{% if user.department %}
|
| 70 |
+
{{ user.department }}
|
| 71 |
+
{% else %}
|
| 72 |
+
<span class="text-gray-400 dark:text-gray-500 italic">None</span>
|
| 73 |
+
{% endif %}
|
| 74 |
+
</td>
|
| 75 |
+
<td class="px-6 py-4 whitespace-nowrap">
|
| 76 |
+
<span class="inline-flex items-center text-xs font-bold text-green-600 dark:text-green-400">
|
| 77 |
+
<span class="w-2 h-2 mr-1.5 bg-green-500 rounded-full animate-pulse"></span> Active
|
| 78 |
+
</span>
|
| 79 |
+
</td>
|
| 80 |
+
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
| 81 |
+
<div class="flex items-center justify-end space-x-3">
|
| 82 |
+
<!-- Reset Password Form -->
|
| 83 |
+
<form action="{{ url_for('admin.reset_password', user_id=user.id) }}" method="POST" onsubmit="return handlePasswordReset(event, '{{ user.email }}', this);">
|
| 84 |
+
<input type="hidden" name="new_password" class="new_password_input">
|
| 85 |
+
<button type="submit" class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 bg-indigo-50 hover:bg-indigo-100 dark:bg-indigo-900/20 dark:hover:bg-indigo-900/40 p-2 rounded-lg transition-colors" title="Change / Reset Password">
|
| 86 |
+
<i class="fa-solid fa-key"></i>
|
| 87 |
+
</button>
|
| 88 |
+
</form>
|
| 89 |
+
|
| 90 |
+
<!-- Delete User Form -->
|
| 91 |
+
{% if user.id != current_user.id %}
|
| 92 |
+
<form action="{{ url_for('admin.delete_user', user_id=user.id) }}" method="POST" onsubmit="return confirm('Are you strictly sure you want to permanently delete the account for {{ user.name }} ({{ user.role }})? This action cannot be undone.');">
|
| 93 |
+
<button type="submit" class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 p-2 rounded-lg transition-colors" title="Delete User">
|
| 94 |
+
<i class="fa-solid fa-trash-can"></i>
|
| 95 |
+
</button>
|
| 96 |
+
</form>
|
| 97 |
+
{% else %}
|
| 98 |
+
<button type="button" class="text-gray-300 dark:text-gray-600 p-2 rounded-lg cursor-not-allowed" title="Cannot delete yourself">
|
| 99 |
+
<i class="fa-solid fa-trash-can"></i>
|
| 100 |
+
</button>
|
| 101 |
+
{% endif %}
|
| 102 |
+
</div>
|
| 103 |
+
</td>
|
| 104 |
+
</tr>
|
| 105 |
+
{% else %}
|
| 106 |
+
<tr>
|
| 107 |
+
<td colspan="5" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
| 108 |
+
<i class="fa-solid fa-user-slash text-4xl mb-3 opacity-50"></i>
|
| 109 |
+
<p>No other users found in the system.</p>
|
| 110 |
+
</td>
|
| 111 |
+
</tr>
|
| 112 |
+
{% endfor %}
|
| 113 |
+
</tbody>
|
| 114 |
+
</table>
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
{% endblock %}
|
| 119 |
+
|
| 120 |
+
{% block extra_scripts %}
|
| 121 |
+
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
| 122 |
+
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
|
| 123 |
+
<script>
|
| 124 |
+
$(document).ready(function() {
|
| 125 |
+
$('#usersTable').DataTable({
|
| 126 |
+
"pageLength": 10,
|
| 127 |
+
"language": {
|
| 128 |
+
"search": "Search Personnel:",
|
| 129 |
+
"lengthMenu": "Show _MENU_ users per page"
|
| 130 |
+
}
|
| 131 |
+
});
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
function handlePasswordReset(event, email, form) {
|
| 135 |
+
event.preventDefault();
|
| 136 |
+
let newPass = prompt(`Enter new custom password for:\n${email}\n\n[Leave this blank and press OK to auto-generate a random 8-character password]`);
|
| 137 |
+
|
| 138 |
+
if (newPass === null) {
|
| 139 |
+
// User pressed Cancel
|
| 140 |
+
return false;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
form.querySelector('.new_password_input').value = newPass;
|
| 144 |
+
form.submit();
|
| 145 |
+
}
|
| 146 |
+
</script>
|
| 147 |
+
{% endblock %}
|
templates/admin/training.html
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}AI Training Center - NeuroSent{% endblock %}
|
| 3 |
+
|
| 4 |
+
{% block content %}
|
| 5 |
+
<div class="relative z-10 max-w-5xl mx-auto pt-4 animate-fade-in">
|
| 6 |
+
<div class="mb-8 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
| 7 |
+
<div>
|
| 8 |
+
<h1 class="text-3xl sm:text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-indigo-600 dark:from-primary-400 dark:to-indigo-400 tracking-tight drop-shadow-sm"><i class="fa-solid fa-brain mr-2"></i>AI Training Center</h1>
|
| 9 |
+
<p class="text-gray-600 dark:text-gray-300 font-medium text-sm mt-2">Fine-tune the neural network using your own proprietary feedback database.</p>
|
| 10 |
+
</div>
|
| 11 |
+
</div>
|
| 12 |
+
|
| 13 |
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 14 |
+
<!-- Status Card -->
|
| 15 |
+
<div class="lg:col-span-2 glass rounded-3xl shadow-xl border border-white/40 dark:border-gray-700/60 p-8 relative overflow-hidden backdrop-blur-2xl">
|
| 16 |
+
<!-- Glowing Orb -->
|
| 17 |
+
<div class="absolute -top-20 -right-20 w-64 h-64 bg-indigo-500/20 dark:bg-indigo-600/20 rounded-full blur-3xl pointer-events-none"></div>
|
| 18 |
+
|
| 19 |
+
<h3 class="text-xl font-bold text-gray-800 dark:text-white mb-6">Training Engine Status</h3>
|
| 20 |
+
|
| 21 |
+
<div class="bg-white/40 dark:bg-gray-800/40 rounded-2xl p-6 border border-white/50 dark:border-gray-700/50 relative z-10">
|
| 22 |
+
<div class="flex items-center justify-between mb-2">
|
| 23 |
+
<span id="status-badge" class="px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
| 24 |
+
Checking Status...
|
| 25 |
+
</span>
|
| 26 |
+
<span id="progress-text" class="text-sm font-bold text-gray-600 dark:text-gray-400">0%</span>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<!-- Progress Bar -->
|
| 30 |
+
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 mb-4 overflow-hidden">
|
| 31 |
+
<div id="progress-bar" class="bg-gradient-to-r from-primary-500 to-indigo-600 h-3 rounded-full transition-all duration-500" style="width: 0%"></div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<!-- Log Output terminal -->
|
| 35 |
+
<div class="bg-gray-900 border border-gray-700 rounded-xl p-4 font-mono text-sm text-green-400 shadow-inner h-32 overflow-y-auto">
|
| 36 |
+
<div id="terminal-output" class="whitespace-pre-line animate-pulse">Initializing connection...</div>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div class="mt-8 flex justify-end">
|
| 41 |
+
<button id="start-btn" onclick="startTraining()" class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-primary-600 to-indigo-600 hover:from-primary-700 hover:to-indigo-700 text-white font-bold rounded-xl shadow-lg hover:shadow-xl transition-all hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed">
|
| 42 |
+
<i class="fa-solid fa-play mr-2"></i> Start Target Fine-Tuning
|
| 43 |
+
</button>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<!-- Info Card -->
|
| 48 |
+
<div class="glass rounded-3xl shadow-xl border border-white/40 dark:border-gray-700/60 p-6 relative overflow-hidden backdrop-blur-2xl">
|
| 49 |
+
<h3 class="text-lg font-bold text-gray-800 dark:text-white mb-4">Current AI Brain</h3>
|
| 50 |
+
|
| 51 |
+
{% if has_custom %}
|
| 52 |
+
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4 mb-6">
|
| 53 |
+
<div class="flex items-start">
|
| 54 |
+
<i class="fa-solid fa-microchip text-green-600 dark:text-green-400 text-xl mt-0.5 mr-3"></i>
|
| 55 |
+
<div>
|
| 56 |
+
<h4 class="font-bold text-green-800 dark:text-green-300 text-sm">Custom Weights Active</h4>
|
| 57 |
+
<p class="text-xs text-green-600 dark:text-green-400 mt-1">The AI is currently processing data using your proprietary locally-trained neural network.</p>
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
{% else %}
|
| 62 |
+
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4 mb-6">
|
| 63 |
+
<div class="flex items-start">
|
| 64 |
+
<i class="fa-solid fa-globe text-blue-600 dark:text-blue-400 text-xl mt-0.5 mr-3"></i>
|
| 65 |
+
<div>
|
| 66 |
+
<h4 class="font-bold text-blue-800 dark:text-blue-300 text-sm">Global Base Model Active</h4>
|
| 67 |
+
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">The AI is running the standard Hugging Face CardiffNLP weights.</p>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
{% endif %}
|
| 72 |
+
|
| 73 |
+
<div class="text-sm text-gray-600 dark:text-gray-400 space-y-3">
|
| 74 |
+
<p><i class="fa-solid fa-circle-info mr-2 opacity-70"></i> <strong>How it works:</strong> Clicking Start extracts all human-reviewed Feedback from the database and compiles it into a Hugging Face Dataset.</p>
|
| 75 |
+
<p><i class="fa-solid fa-forward-fast mr-2 opacity-70"></i> It runs 2 Epochs through the RoBERTa Transformer architecture locally to update the attention weights.</p>
|
| 76 |
+
<p><i class="fa-solid fa-triangle-exclamation mr-2 opacity-70 text-orange-500"></i> Minimum 50 database rows required.</p>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
{% endblock %}
|
| 82 |
+
|
| 83 |
+
{% block extra_scripts %}
|
| 84 |
+
<script>
|
| 85 |
+
let statusInterval = null;
|
| 86 |
+
|
| 87 |
+
function updateBadge(status) {
|
| 88 |
+
const badge = document.getElementById('status-badge');
|
| 89 |
+
badge.textContent = status;
|
| 90 |
+
|
| 91 |
+
badge.className = 'px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider ';
|
| 92 |
+
if (status === 'Idle') badge.className += 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
| 93 |
+
else if (status === 'Error') badge.className += 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 border border-red-200 dark:border-red-800';
|
| 94 |
+
else if (status === 'Completed') badge.className += 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 border border-green-200 dark:border-green-800';
|
| 95 |
+
else badge.className += 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 border border-blue-200 dark:border-blue-800 animate-pulse';
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
function checkStatus() {
|
| 99 |
+
fetch('{{ url_for("training.check_status") }}')
|
| 100 |
+
.then(res => res.json())
|
| 101 |
+
.then(data => {
|
| 102 |
+
updateBadge(data.status);
|
| 103 |
+
document.getElementById('progress-bar').style.width = data.progress + '%';
|
| 104 |
+
document.getElementById('progress-text').textContent = data.progress + '%';
|
| 105 |
+
|
| 106 |
+
const term = document.getElementById('terminal-output');
|
| 107 |
+
if (term.innerText !== data.message) {
|
| 108 |
+
term.innerText += '\n> ' + data.message;
|
| 109 |
+
term.scrollTop = term.scrollHeight; // Auto-scroll to bottom
|
| 110 |
+
term.classList.remove('animate-pulse');
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
const startBtn = document.getElementById('start-btn');
|
| 114 |
+
if (['Initializing', 'Processing', 'Training', 'Saving', 'Starting'].includes(data.status)) {
|
| 115 |
+
startBtn.disabled = true;
|
| 116 |
+
startBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> Engine Active...';
|
| 117 |
+
} else {
|
| 118 |
+
startBtn.disabled = false;
|
| 119 |
+
startBtn.innerHTML = '<i class="fa-solid fa-play mr-2"></i> Start Target Fine-Tuning';
|
| 120 |
+
if (data.status === 'Completed' || data.status === 'Error') {
|
| 121 |
+
clearInterval(statusInterval);
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
})
|
| 125 |
+
.catch(err => console.error(err));
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
function startTraining() {
|
| 129 |
+
if (!confirm("Start Neural Network Fine-Tuning? This will heavily utilize CPU/GPU for the duration of the training.")) return;
|
| 130 |
+
|
| 131 |
+
fetch('{{ url_for("training.start_training") }}', { method: 'POST' })
|
| 132 |
+
.then(res => res.json())
|
| 133 |
+
.then(data => {
|
| 134 |
+
if(data.success) {
|
| 135 |
+
document.getElementById('terminal-output').innerText = "> Launching external training script pipeline...";
|
| 136 |
+
checkStatus();
|
| 137 |
+
clearInterval(statusInterval);
|
| 138 |
+
statusInterval = setInterval(checkStatus, 3000);
|
| 139 |
+
} else {
|
| 140 |
+
alert("Failed to start training: " + data.message);
|
| 141 |
+
}
|
| 142 |
+
});
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
// Initial check
|
| 146 |
+
checkStatus();
|
| 147 |
+
statusInterval = setInterval(checkStatus, 3000);
|
| 148 |
+
</script>
|
| 149 |
+
{% endblock %}
|
templates/auth/login.html
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Login - NeuroSent{% endblock %}
|
| 3 |
+
|
| 4 |
+
{% block unauth_content %}
|
| 5 |
+
<div class="flex items-center justify-center min-h-[80vh] px-4 animate-fade-in">
|
| 6 |
+
<div class="w-full max-w-md p-8 sm:p-10 glass rounded-3xl shadow-2xl relative overflow-hidden backdrop-blur-xl border border-white/20 dark:border-gray-700/50">
|
| 7 |
+
|
| 8 |
+
<!-- Decorative gradient orb -->
|
| 9 |
+
<div class="absolute -top-16 -right-16 w-40 h-40 bg-primary-500/20 rounded-full blur-3xl pointer-events-none transform animate-pulse transition-all"></div>
|
| 10 |
+
<div class="absolute -bottom-16 -left-16 w-40 h-40 bg-indigo-500/20 rounded-full blur-3xl pointer-events-none transform animate-pulse animation-delay-2000 transition-all"></div>
|
| 11 |
+
|
| 12 |
+
<div class="relative z-10 text-center mb-8">
|
| 13 |
+
<a href="#" class="inline-flex items-center space-x-3 text-4xl font-extrabold text-gradient mb-3 tracking-tight">
|
| 14 |
+
<i class="fa-solid fa-brain"></i>
|
| 15 |
+
<span>NeuroSent</span>
|
| 16 |
+
</a>
|
| 17 |
+
<p class="text-sm text-gray-500 dark:text-gray-400 font-medium">AI-Based Sentiment Analysis Platform</p>
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
<form method="POST" action="{{ url_for('auth.login') }}" class="relative z-10 space-y-6">
|
| 21 |
+
<div>
|
| 22 |
+
<label for="email" class="block mb-2 text-sm font-semibold text-gray-900 dark:text-gray-300">Your email</label>
|
| 23 |
+
<div class="relative">
|
| 24 |
+
<div class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
|
| 25 |
+
<i class="fa-solid fa-envelope text-primary-400"></i>
|
| 26 |
+
</div>
|
| 27 |
+
<input type="email" name="email" id="email" class="bg-white/60 border border-gray-300/50 text-gray-900 sm:text-sm rounded-xl focus:ring-primary-500 focus:border-primary-500 block w-full pl-11 p-3.5 dark:bg-gray-700/60 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white shadow-sm transition-all focus:bg-white dark:focus:bg-gray-800" placeholder="name@company.com" required="">
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
<div>
|
| 31 |
+
<label for="password" class="block mb-2 text-sm font-semibold text-gray-900 dark:text-gray-300">Password</label>
|
| 32 |
+
<div class="relative">
|
| 33 |
+
<div class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
|
| 34 |
+
<i class="fa-solid fa-lock text-primary-400"></i>
|
| 35 |
+
</div>
|
| 36 |
+
<input type="password" name="password" id="password" placeholder="••••••••" class="bg-white/60 border border-gray-300/50 text-gray-900 sm:text-sm rounded-xl focus:ring-primary-500 focus:border-primary-500 block w-full pl-11 p-3.5 dark:bg-gray-700/60 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white shadow-sm transition-all focus:bg-white dark:focus:bg-gray-800" required="">
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<button type="submit" class="w-full text-white bg-gradient-to-r from-primary-600 to-indigo-600 hover:from-primary-700 hover:to-indigo-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-bold rounded-xl text-sm px-5 py-3.5 text-center dark:focus:ring-primary-900 shadow-lg hover:shadow-xl transform transition-all hover:-translate-y-0.5 mt-2">
|
| 41 |
+
Sign in to your account
|
| 42 |
+
</button>
|
| 43 |
+
<p class="text-sm font-medium text-center text-gray-600 dark:text-gray-400 mt-6">
|
| 44 |
+
Don't have an account yet? <a href="{{ url_for('auth.register') }}" class="font-bold text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 transition-colors">Sign up</a>
|
| 45 |
+
</p>
|
| 46 |
+
</form>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
{% endblock %}
|
templates/auth/register.html
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Register - NeuroSent{% endblock %}
|
| 3 |
+
|
| 4 |
+
{% block unauth_content %}
|
| 5 |
+
<div class="flex items-center justify-center min-h-[85vh] py-8 px-4 animate-fade-in">
|
| 6 |
+
<div class="w-full max-w-md p-8 sm:p-10 glass rounded-3xl shadow-2xl relative overflow-hidden backdrop-blur-xl border border-white/20 dark:border-gray-700/50">
|
| 7 |
+
|
| 8 |
+
<!-- Decorative gradient orb -->
|
| 9 |
+
<div class="absolute -bottom-16 -right-16 w-40 h-40 bg-pink-500/20 rounded-full blur-3xl pointer-events-none transform animate-pulse transition-all"></div>
|
| 10 |
+
<div class="absolute -top-16 -left-16 w-40 h-40 bg-blue-500/20 rounded-full blur-3xl pointer-events-none transform animate-pulse animation-delay-2000 transition-all"></div>
|
| 11 |
+
|
| 12 |
+
<div class="relative z-10 text-center mb-8">
|
| 13 |
+
<h2 class="text-3xl font-extrabold text-gray-900 dark:text-white mb-2 tracking-tight">Create an Account</h2>
|
| 14 |
+
<p class="text-sm text-gray-500 dark:text-gray-400 font-medium">Join the NeuroSent analytics platform</p>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<form method="POST" action="{{ url_for('auth.register') }}" class="relative z-10 space-y-5">
|
| 18 |
+
|
| 19 |
+
<div>
|
| 20 |
+
<label for="name" class="block mb-2 text-sm font-semibold text-gray-900 dark:text-gray-300">Full Name</label>
|
| 21 |
+
<div class="relative">
|
| 22 |
+
<div class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
|
| 23 |
+
<i class="fa-solid fa-user text-primary-400"></i>
|
| 24 |
+
</div>
|
| 25 |
+
<input type="text" name="name" id="name" class="bg-white/60 border border-gray-300/50 text-gray-900 sm:text-sm rounded-xl focus:ring-primary-500 focus:border-primary-500 block w-full pl-11 p-3.5 dark:bg-gray-700/60 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white shadow-sm transition-all focus:bg-white dark:focus:bg-gray-800" placeholder="John Doe" required="">
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<div>
|
| 30 |
+
<label for="email" class="block mb-2 text-sm font-semibold text-gray-900 dark:text-gray-300">Email address</label>
|
| 31 |
+
<div class="relative">
|
| 32 |
+
<div class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
|
| 33 |
+
<i class="fa-solid fa-envelope text-primary-400"></i>
|
| 34 |
+
</div>
|
| 35 |
+
<input type="email" name="email" id="email" class="bg-white/60 border border-gray-300/50 text-gray-900 sm:text-sm rounded-xl focus:ring-primary-500 focus:border-primary-500 block w-full pl-11 p-3.5 dark:bg-gray-700/60 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white shadow-sm transition-all focus:bg-white dark:focus:bg-gray-800" placeholder="name@company.com" required="">
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
<div>
|
| 40 |
+
<label for="password" class="block mb-2 text-sm font-semibold text-gray-900 dark:text-gray-300">Password</label>
|
| 41 |
+
<div class="relative">
|
| 42 |
+
<div class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
|
| 43 |
+
<i class="fa-solid fa-lock text-primary-400"></i>
|
| 44 |
+
</div>
|
| 45 |
+
<input type="password" name="password" id="password" placeholder="••••••••" class="bg-white/60 border border-gray-300/50 text-gray-900 sm:text-sm rounded-xl focus:ring-primary-500 focus:border-primary-500 block w-full pl-11 p-3.5 dark:bg-gray-700/60 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white shadow-sm transition-all focus:bg-white dark:focus:bg-gray-800" required="">
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<div class="grid grid-cols-2 gap-4">
|
| 50 |
+
<div class="col-span-2">
|
| 51 |
+
<label for="department" class="block mb-2 text-sm font-semibold text-gray-900 dark:text-gray-300">Department</label>
|
| 52 |
+
<input type="text" name="department" id="department" class="bg-white/60 border border-gray-300/50 text-gray-900 sm:text-sm rounded-xl focus:ring-primary-500 focus:border-primary-500 block w-full p-3.5 dark:bg-gray-700/60 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white shadow-sm transition-all focus:bg-white dark:focus:bg-gray-800" placeholder="e.g. IT, HR" required="">
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<button type="submit" class="w-full text-white bg-gradient-to-r from-primary-600 to-indigo-600 hover:from-primary-700 hover:to-indigo-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-bold rounded-xl text-sm px-5 py-3.5 text-center dark:focus:ring-primary-900 shadow-lg hover:shadow-xl transform transition-all hover:-translate-y-0.5 mt-6">
|
| 57 |
+
Create Account
|
| 58 |
+
</button>
|
| 59 |
+
<p class="text-sm font-medium text-center text-gray-600 dark:text-gray-400 mt-6">
|
| 60 |
+
Already have an account? <a href="{{ url_for('auth.login') }}" class="font-bold text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 transition-colors">Login here</a>
|
| 61 |
+
</p>
|
| 62 |
+
</form>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
{% endblock %}
|
templates/base.html
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" class="light">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{% block title %}AI Sentiment Analysis{% endblock %}</title>
|
| 7 |
+
<!-- Tailwind CSS (via CDN for setup, preferably local build in prod) -->
|
| 8 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 9 |
+
<script>
|
| 10 |
+
tailwind.config = {
|
| 11 |
+
darkMode: 'class',
|
| 12 |
+
theme: {
|
| 13 |
+
extend: {
|
| 14 |
+
colors: {
|
| 15 |
+
primary: {"50":"#eff6ff","100":"#dbeafe","200":"#bfdbfe","300":"#93c5fd","400":"#60a5fa","500":"#3b82f6","600":"#2563eb","700":"#1d4ed8","800":"#1e40af","900":"#1e3a8a","950":"#172554"}
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
// Early dark mode script to prevent FOUC
|
| 21 |
+
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
| 22 |
+
document.documentElement.classList.add('dark');
|
| 23 |
+
} else {
|
| 24 |
+
document.documentElement.classList.remove('dark')
|
| 25 |
+
}
|
| 26 |
+
</script>
|
| 27 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 28 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
| 29 |
+
<!-- Load Three.js and Vanta.js globally for extreme aesthetic flow -->
|
| 30 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script>
|
| 31 |
+
<script src="https://cdn.jsdelivr.net/npm/vanta@latest/dist/vanta.net.min.js"></script>
|
| 32 |
+
{% block extra_head %}{% endblock %}
|
| 33 |
+
</head>
|
| 34 |
+
<body class="bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-200 transition-colors duration-300">
|
| 35 |
+
<!-- Global Vanta.js Background Container. Use absolute positioning to fill screen without disrupting flow -->
|
| 36 |
+
<div id="vanta-bg" class="fixed inset-0 z-0 pointer-events-none opacity-40 dark:opacity-30 mix-blend-screen transition-opacity duration-1000"></div>
|
| 37 |
+
|
| 38 |
+
{% if current_user.is_authenticated %}
|
| 39 |
+
<!-- Sidebar Setup -->
|
| 40 |
+
<div class="flex h-screen overflow-hidden">
|
| 41 |
+
|
| 42 |
+
<!-- Sidebar -->
|
| 43 |
+
<aside id="sidebar" class="sidebar absolute z-50 flex flex-col w-64 h-screen px-4 py-8 overflow-y-auto bg-white/50 glass border-r border-gray-200/50 -translate-x-full lg:relative lg:translate-x-0 dark:bg-gray-800/40 dark:border-gray-700/50 transition-all duration-300 ease-in-out shadow-2xl">
|
| 44 |
+
<div class="flex items-center justify-between mb-6">
|
| 45 |
+
<a href="{{ url_for('dashboard.index') }}" class="flex items-center space-x-2 text-2xl font-bold text-gradient tracking-tight">
|
| 46 |
+
<i class="fa-solid fa-brain"></i>
|
| 47 |
+
<span>NeuroSent</span>
|
| 48 |
+
</a>
|
| 49 |
+
<button class="sidebar-toggle-btn lg:hidden text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none transition-transform hover:scale-110">
|
| 50 |
+
<i class="fa-solid fa-times text-xl"></i>
|
| 51 |
+
</button>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<div class="flex items-center px-4 py-3 mb-6 bg-white/40 glass rounded-xl dark:bg-gray-800/40 shadow-sm hover:shadow-md transition-shadow">
|
| 55 |
+
<div class="flex-shrink-0">
|
| 56 |
+
<div class="w-10 h-10 rounded-full bg-gradient-to-r from-blue-500 to-indigo-600 flex items-center justify-center text-white font-bold text-lg shadow-inner ring-2 ring-white/20">
|
| 57 |
+
{{ current_user.name[0] }}
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
<div class="ml-3">
|
| 61 |
+
<p class="text-sm font-semibold text-gray-800 dark:text-gray-100">{{ current_user.name }}</p>
|
| 62 |
+
<p class="text-xs text-primary-600 dark:text-primary-400 font-medium">{{ current_user.role }}</p>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<nav class="flex-1 space-y-2">
|
| 67 |
+
<a href="{{ url_for('dashboard.index') }}" class="flex items-center px-4 py-2.5 text-sm font-medium transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 {% if request.endpoint == 'dashboard.index' %}bg-primary-50 text-primary-600 dark:bg-gray-700 dark:text-primary-400{% endif %}">
|
| 68 |
+
<i class="fa-solid fa-chart-line w-5"></i>
|
| 69 |
+
<span class="ml-3">Dashboard</span>
|
| 70 |
+
</a>
|
| 71 |
+
|
| 72 |
+
<a href="{{ url_for('profile.index') }}" class="flex items-center px-4 py-2.5 text-sm font-medium transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 {% if request.endpoint == 'profile.index' %}bg-primary-50 text-primary-600 dark:bg-gray-700 dark:text-primary-400{% endif %}">
|
| 73 |
+
<i class="fa-solid fa-user-circle w-5"></i>
|
| 74 |
+
<span class="ml-3">My Profile</span>
|
| 75 |
+
</a>
|
| 76 |
+
|
| 77 |
+
{% if current_user.role == 'Admin' %}
|
| 78 |
+
<a href="{{ url_for('admin.create_user') }}" class="flex items-center px-4 py-2.5 text-sm font-medium transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 {% if request.endpoint == 'admin.create_user' %}bg-primary-50 text-primary-600 dark:bg-gray-700 dark:text-primary-400{% endif %}">
|
| 79 |
+
<i class="fa-solid fa-user-plus w-5"></i>
|
| 80 |
+
<span class="ml-3">Create User</span>
|
| 81 |
+
</a>
|
| 82 |
+
|
| 83 |
+
<a href="{{ url_for('admin.manage_users') }}" class="flex items-center px-4 py-2.5 text-sm font-medium transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 {% if request.endpoint == 'admin.manage_users' %}bg-primary-50 text-primary-600 dark:bg-gray-700 dark:text-primary-400{% endif %}">
|
| 84 |
+
<i class="fa-solid fa-users-gear w-5"></i>
|
| 85 |
+
<span class="ml-3">Manage Users</span>
|
| 86 |
+
</a>
|
| 87 |
+
|
| 88 |
+
<a href="{{ url_for('training.index') }}" class="flex items-center px-4 py-2.5 text-sm font-medium transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 {% if 'training' in request.endpoint %}bg-primary-50 text-indigo-600 dark:bg-gray-700 dark:text-indigo-400 font-bold{% endif %}">
|
| 89 |
+
<i class="fa-solid fa-microchip w-5"></i>
|
| 90 |
+
<span class="ml-3">AI Training Center</span>
|
| 91 |
+
</a>
|
| 92 |
+
{% endif %}
|
| 93 |
+
|
| 94 |
+
{% if current_user.role in ['Admin', 'HOD', 'Staff'] %}
|
| 95 |
+
<a href="{{ url_for('upload.index') }}" class="flex items-center px-4 py-2.5 text-sm font-medium transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 {% if request.endpoint == 'upload.index' %}bg-primary-50 text-primary-600 dark:bg-gray-700 dark:text-primary-400{% endif %}">
|
| 96 |
+
<i class="fa-solid fa-cloud-upload-alt w-5"></i>
|
| 97 |
+
<span class="ml-3">Upload Data</span>
|
| 98 |
+
</a>
|
| 99 |
+
{% endif %}
|
| 100 |
+
|
| 101 |
+
<a href="{{ url_for('feedback.submit') }}" class="flex items-center px-4 py-2.5 text-sm font-medium transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 {% if request.endpoint == 'feedback.submit' %}bg-primary-50 text-primary-600 dark:bg-gray-700 dark:text-primary-400{% endif %}">
|
| 102 |
+
<i class="fa-solid fa-pen-to-square w-5"></i>
|
| 103 |
+
<span class="ml-3">Submit Feedback</span>
|
| 104 |
+
</a>
|
| 105 |
+
</nav>
|
| 106 |
+
|
| 107 |
+
<div class="mt-8 border-t border-gray-200/50 dark:border-gray-700/50 pt-4">
|
| 108 |
+
<button id="theme-toggle" type="button" class="w-full flex items-center px-4 py-2.5 text-sm font-medium transition-all duration-200 rounded-lg hover:bg-gray-100/50 dark:hover:bg-gray-700/50 hover:pl-5">
|
| 109 |
+
<i id="theme-toggle-dark-icon" class="fa-solid fa-moon w-5 hidden text-indigo-400"></i>
|
| 110 |
+
<i id="theme-toggle-light-icon" class="fa-solid fa-sun w-5 hidden text-yellow-500"></i>
|
| 111 |
+
<span class="ml-3">Toggle Theme</span>
|
| 112 |
+
</button>
|
| 113 |
+
<a href="{{ url_for('auth.logout') }}" class="flex items-center px-4 py-2.5 mt-2 text-sm font-medium text-red-600 transition-all duration-200 rounded-lg hover:bg-red-50/50 dark:hover:bg-red-900/20 dark:text-red-400 hover:pl-5">
|
| 114 |
+
<i class="fa-solid fa-sign-out-alt w-5"></i>
|
| 115 |
+
<span class="ml-3">Logout</span>
|
| 116 |
+
</a>
|
| 117 |
+
</div>
|
| 118 |
+
</aside>
|
| 119 |
+
|
| 120 |
+
<!-- Main content -->
|
| 121 |
+
<main class="flex-1 flex flex-col h-screen overflow-hidden bg-transparent relative z-10">
|
| 122 |
+
<!-- Mobile Header -->
|
| 123 |
+
<header class="flex items-center justify-between px-6 py-4 glass border-b border-gray-200/50 lg:hidden shadow-sm">
|
| 124 |
+
<a href="{{ url_for('dashboard.index') }}" class="text-xl font-bold text-gradient">
|
| 125 |
+
<i class="fa-solid fa-brain"></i> NeuroSent
|
| 126 |
+
</a>
|
| 127 |
+
<button class="sidebar-toggle-btn text-gray-500 focus:outline-none focus:text-gray-700 dark:text-gray-400 dark:focus:text-gray-200">
|
| 128 |
+
<i class="fa-solid fa-bars text-xl"></i>
|
| 129 |
+
</button>
|
| 130 |
+
</header>
|
| 131 |
+
|
| 132 |
+
<div class="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8 animate-fade-in relative z-20">
|
| 133 |
+
<!-- Flash Messages -->
|
| 134 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 135 |
+
{% if messages %}
|
| 136 |
+
<div class="mb-6 space-y-3 z-50">
|
| 137 |
+
{% for category, message in messages %}
|
| 138 |
+
<div class="alert-{{ category }} alert-auto-dismiss px-5 py-4 rounded-xl shadow-lg border-l-4 flex items-center justify-between animate-fade-in">
|
| 139 |
+
<div><i class="fa-solid fa-circle-info mr-2 opacity-80"></i> {{ message }}</div>
|
| 140 |
+
</div>
|
| 141 |
+
{% endfor %}
|
| 142 |
+
</div>
|
| 143 |
+
{% endif %}
|
| 144 |
+
{% endwith %}
|
| 145 |
+
|
| 146 |
+
<div class="animation-delay-100">
|
| 147 |
+
{% block content %}{% endblock %}
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</main>
|
| 151 |
+
</div>
|
| 152 |
+
{% else %}
|
| 153 |
+
<!-- No Sidebar for Unauthenticated Users -->
|
| 154 |
+
<div class="min-h-screen flex flex-col">
|
| 155 |
+
<div class="flex-1">
|
| 156 |
+
<!-- Flash Messages -->
|
| 157 |
+
<div class="max-w-md mx-auto mt-4 px-4">
|
| 158 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 159 |
+
{% if messages %}
|
| 160 |
+
{% for category, message in messages %}
|
| 161 |
+
<div class="alert-{{ category }} alert-auto-dismiss px-4 py-3 rounded-lg shadow-sm mb-2">
|
| 162 |
+
<i class="fa-solid fa-circle-info mr-2"></i> {{ message }}
|
| 163 |
+
</div>
|
| 164 |
+
{% endfor %}
|
| 165 |
+
{% endif %}
|
| 166 |
+
{% endwith %}
|
| 167 |
+
</div>
|
| 168 |
+
|
| 169 |
+
{% block unauth_content %}{% endblock %}
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
<button id="theme-toggle" type="button" class="fixed bottom-4 right-4 p-3 rounded-full bg-white dark:bg-gray-800 shadow-lg text-gray-500 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 focus:outline-none transition-colors">
|
| 173 |
+
<i id="theme-toggle-dark-icon" class="fa-solid fa-moon text-xl hidden"></i>
|
| 174 |
+
<i id="theme-toggle-light-icon" class="fa-solid fa-sun text-xl hidden"></i>
|
| 175 |
+
</button>
|
| 176 |
+
</div>
|
| 177 |
+
{% endif %}
|
| 178 |
+
|
| 179 |
+
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
| 180 |
+
<script>
|
| 181 |
+
// Global Vanta.js Initialization for supreme aesthetics
|
| 182 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 183 |
+
const isDark = document.documentElement.classList.contains('dark');
|
| 184 |
+
|
| 185 |
+
// Deeper blue for light mode network lines, brighter blue for dark mode
|
| 186 |
+
const vantaEffect = VANTA.NET({
|
| 187 |
+
el: "#vanta-bg",
|
| 188 |
+
mouseControls: true,
|
| 189 |
+
touchControls: true,
|
| 190 |
+
gyroControls: false,
|
| 191 |
+
minHeight: 200.00,
|
| 192 |
+
minWidth: 200.00,
|
| 193 |
+
scale: 1.00,
|
| 194 |
+
scaleMobile: 1.00,
|
| 195 |
+
color: isDark ? 0x60a5fa : 0x1d4ed8, // primary-400 (Dark) / primary-700 (Light)
|
| 196 |
+
backgroundColor: isDark ? 0x111827 : 0xf9fafb, // gray-900 / gray-50
|
| 197 |
+
points: 10.00,
|
| 198 |
+
maxDistance: 20.00,
|
| 199 |
+
spacing: 18.00,
|
| 200 |
+
showDots: true
|
| 201 |
+
});
|
| 202 |
+
|
| 203 |
+
// Handle theme toggling seamlessly without reloading
|
| 204 |
+
const observer = new MutationObserver((mutations) => {
|
| 205 |
+
mutations.forEach((mutation) => {
|
| 206 |
+
if (mutation.attributeName === 'class') {
|
| 207 |
+
const isDarkTheme = document.documentElement.classList.contains('dark');
|
| 208 |
+
vantaEffect.setOptions({
|
| 209 |
+
color: isDarkTheme ? 0x60a5fa : 0x1d4ed8,
|
| 210 |
+
backgroundColor: isDarkTheme ? 0x111827 : 0xf9fafb
|
| 211 |
+
});
|
| 212 |
+
}
|
| 213 |
+
});
|
| 214 |
+
});
|
| 215 |
+
|
| 216 |
+
observer.observe(document.documentElement, { attributes: true });
|
| 217 |
+
});
|
| 218 |
+
</script>
|
| 219 |
+
{% block extra_scripts %}{% endblock %}
|
| 220 |
+
</body>
|
| 221 |
+
</html>
|
templates/dashboard/admin.html
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Admin Dashboard - NeuroSent{% endblock %}
|
| 3 |
+
|
| 4 |
+
{% block extra_scripts %}
|
| 5 |
+
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.min.css">
|
| 6 |
+
<style>
|
| 7 |
+
/* Glassmorphism overrides for DataTables */
|
| 8 |
+
.dataTables_wrapper .dataTables_length, .dataTables_wrapper .dataTables_filter, .dataTables_wrapper .dataTables_info, .dataTables_wrapper .dataTables_processing, .dataTables_wrapper .dataTables_paginate {
|
| 9 |
+
color: inherit;
|
| 10 |
+
padding: 1rem;
|
| 11 |
+
}
|
| 12 |
+
.dataTables_wrapper .dataTables_paginate .paginate_button {
|
| 13 |
+
border-radius: 0.5rem;
|
| 14 |
+
color: inherit !important;
|
| 15 |
+
}
|
| 16 |
+
.dataTables_wrapper .dataTables_paginate .paginate_button.current, .dataTables_wrapper .dataTables_paginate .paginate_button.current:hover {
|
| 17 |
+
background: rgba(99, 102, 241, 0.2);
|
| 18 |
+
border: 1px solid rgba(99, 102, 241, 0.5);
|
| 19 |
+
color: inherit !important;
|
| 20 |
+
}
|
| 21 |
+
table.dataTable.no-footer {
|
| 22 |
+
border-bottom: 1px solid rgba(255,255,255,0.1);
|
| 23 |
+
}
|
| 24 |
+
</style>
|
| 25 |
+
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
| 26 |
+
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
|
| 27 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/wordcloud2.js/1.2.2/wordcloud2.min.js"></script>
|
| 28 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
| 29 |
+
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
|
| 30 |
+
<script>
|
| 31 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 32 |
+
renderSentimentPieChart('sentimentPieChart', {
|
| 33 |
+
positive: {{ positive|default(0) }},
|
| 34 |
+
negative: {{ negative|default(0) }},
|
| 35 |
+
neutral: {{ neutral|default(0) }}
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
// Process department dictionary to arrays
|
| 39 |
+
const deptLabels = [];
|
| 40 |
+
const deptData = [];
|
| 41 |
+
{% for dept, count in dept_distribution.items() %}
|
| 42 |
+
deptLabels.push("{{ dept|default('Uncategorized', true) }}");
|
| 43 |
+
deptData.push({{ count }});
|
| 44 |
+
{% endfor %}
|
| 45 |
+
|
| 46 |
+
renderDepartmentBarChart('deptBarChart', deptLabels, deptData);
|
| 47 |
+
|
| 48 |
+
// Initialize DataTable
|
| 49 |
+
$('#recentFeedbackTable').DataTable({
|
| 50 |
+
"pageLength": 5,
|
| 51 |
+
"lengthMenu": [5, 10, 25, 50],
|
| 52 |
+
"language": {
|
| 53 |
+
"search": "Filter Feedback:",
|
| 54 |
+
"info": "Showing _START_ to _END_ of _TOTAL_ entries"
|
| 55 |
+
}
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
// Fetch and render asynchronous Word Cloud
|
| 59 |
+
fetch('{{ url_for("dashboard.get_keywords") }}')
|
| 60 |
+
.then(response => response.json())
|
| 61 |
+
.then(data => {
|
| 62 |
+
if (data.keywords && data.keywords.length > 0) {
|
| 63 |
+
// WordCloud2 options
|
| 64 |
+
WordCloud(document.getElementById('keywordCloud'), {
|
| 65 |
+
list: data.keywords,
|
| 66 |
+
weightFactor: 8,
|
| 67 |
+
fontFamily: 'Inter, sans-serif',
|
| 68 |
+
color: function (word, weight, fontSize, distance, theta) {
|
| 69 |
+
return weight > 10 ? '#ef4444' : (weight > 5 ? '#f59e0b' : '#3b82f6');
|
| 70 |
+
},
|
| 71 |
+
backgroundColor: 'transparent'
|
| 72 |
+
});
|
| 73 |
+
} else {
|
| 74 |
+
document.getElementById('keywordCloud').parentElement.innerHTML = '<div class="flex h-full items-center justify-center text-gray-400 italic">Not enough text data to extract keywords</div>';
|
| 75 |
+
}
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
// Date Range Filter Logic
|
| 79 |
+
document.getElementById('applyDateFilter').addEventListener('click', function() {
|
| 80 |
+
const startDate = document.getElementById('startDate').value;
|
| 81 |
+
const endDate = document.getElementById('endDate').value;
|
| 82 |
+
const btn = this;
|
| 83 |
+
|
| 84 |
+
if(!startDate && !endDate) return;
|
| 85 |
+
|
| 86 |
+
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
|
| 87 |
+
|
| 88 |
+
fetch(`{{ url_for("dashboard.get_dashboard_data") }}?start_date=${startDate}&end_date=${endDate}`)
|
| 89 |
+
.then(res => res.json())
|
| 90 |
+
.then(data => {
|
| 91 |
+
btn.innerHTML = '<i class="fa-solid fa-filter"></i> Apply';
|
| 92 |
+
if(data.success) {
|
| 93 |
+
// Update Stat Cards (with basic rolling animation simulation)
|
| 94 |
+
document.getElementById('statTotal').innerText = data.total_count;
|
| 95 |
+
document.getElementById('statPos').innerText = data.positive;
|
| 96 |
+
document.getElementById('statNeg').innerText = data.negative;
|
| 97 |
+
document.getElementById('statNeu').innerText = data.neutral;
|
| 98 |
+
|
| 99 |
+
document.getElementById('pctPos').innerText = data.pct_pos + '%';
|
| 100 |
+
document.getElementById('pctNeg').innerText = data.pct_neg + '%';
|
| 101 |
+
document.getElementById('pctNeu').innerText = data.pct_neu + '%';
|
| 102 |
+
|
| 103 |
+
// Update Charts
|
| 104 |
+
const pieChart = Chart.instances[0];
|
| 105 |
+
if(pieChart) {
|
| 106 |
+
pieChart.data.datasets[0].data = [data.positive, data.negative, data.neutral];
|
| 107 |
+
pieChart.update();
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
const barChart = Chart.instances[1];
|
| 111 |
+
if(barChart) {
|
| 112 |
+
barChart.data.labels = data.dept_labels;
|
| 113 |
+
barChart.data.datasets[0].data = data.dept_counts;
|
| 114 |
+
barChart.update();
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
})
|
| 118 |
+
.catch(err => {
|
| 119 |
+
btn.innerHTML = '<i class="fa-solid fa-filter"></i> Apply';
|
| 120 |
+
console.error("Date filter error:", err);
|
| 121 |
+
});
|
| 122 |
+
});
|
| 123 |
+
});
|
| 124 |
+
</script>
|
| 125 |
+
{% endblock %}
|
| 126 |
+
|
| 127 |
+
{% block content %}
|
| 128 |
+
<div class="mb-8 flex flex-col md:flex-row justify-between items-start md:items-end animate-fade-in">
|
| 129 |
+
<div>
|
| 130 |
+
<h1 class="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">Admin Dashboard</h1>
|
| 131 |
+
<p class="text-gray-500 dark:text-gray-400 text-sm mt-2">System-wide sentiment overview and data management.</p>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
<div class="mt-4 md:mt-0 flex flex-col sm:flex-row space-y-3 sm:space-y-0 sm:space-x-3 items-end">
|
| 135 |
+
<!-- Date Filter UI -->
|
| 136 |
+
<div class="flex items-center space-x-2 bg-white/20 dark:bg-gray-800/40 p-1.5 rounded-xl border border-white/20 dark:border-gray-700/50 backdrop-blur-md">
|
| 137 |
+
<input type="date" id="startDate" class="bg-transparent border-none text-xs text-gray-700 dark:text-gray-300 focus:ring-0 cursor-pointer" title="Start Date">
|
| 138 |
+
<span class="text-gray-400 text-xs">to</span>
|
| 139 |
+
<input type="date" id="endDate" class="bg-transparent border-none text-xs text-gray-700 dark:text-gray-300 focus:ring-0 cursor-pointer" title="End Date">
|
| 140 |
+
<button id="applyDateFilter" class="px-3 py-1.5 text-xs font-bold text-white bg-indigo-500 hover:bg-indigo-600 rounded-lg transition-colors shadow-sm">
|
| 141 |
+
<i class="fa-solid fa-filter"></i> Apply
|
| 142 |
+
</button>
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
<div class="flex space-x-2">
|
| 146 |
+
<a href="{{ url_for('reports.download_excel') }}" class="px-4 py-2 text-sm font-semibold text-emerald-700 bg-emerald-100/80 hover:bg-emerald-200 dark:bg-emerald-500/20 dark:text-emerald-300 dark:hover:bg-emerald-500/30 rounded-xl transition-all shadow-sm hover:shadow" title="Export Excel">
|
| 147 |
+
<i class="fa-solid fa-file-excel"></i>
|
| 148 |
+
</a>
|
| 149 |
+
<a href="{{ url_for('reports.download_pdf') }}" class="px-4 py-2 text-sm font-semibold text-rose-700 bg-rose-100/80 hover:bg-rose-200 dark:bg-rose-500/20 dark:text-rose-300 dark:hover:bg-rose-500/30 rounded-xl transition-all shadow-sm hover:shadow" title="Export PDF">
|
| 150 |
+
<i class="fa-solid fa-file-pdf"></i>
|
| 151 |
+
</a>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
<!-- Stats Overview -->
|
| 157 |
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 animate-fade-in animation-delay-100">
|
| 158 |
+
<div class="glass rounded-2xl shadow-sm p-6 hover-card">
|
| 159 |
+
<div class="flex items-center">
|
| 160 |
+
<div class="p-4 rounded-xl bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
| 161 |
+
<i class="fa-solid fa-database text-2xl"></i>
|
| 162 |
+
</div>
|
| 163 |
+
<div class="ml-5">
|
| 164 |
+
<p class="mb-1 text-sm text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wider">Total Feedback</p>
|
| 165 |
+
<h3 id="statTotal" class="text-3xl font-bold text-gray-900 dark:text-white">{{ total_count }}</h3>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
<div class="glass rounded-2xl shadow-sm p-6 hover-card">
|
| 170 |
+
<div class="flex items-center">
|
| 171 |
+
<div class="p-4 rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
| 172 |
+
<i class="fa-solid fa-face-smile text-2xl"></i>
|
| 173 |
+
</div>
|
| 174 |
+
<div class="ml-5">
|
| 175 |
+
<p class="mb-1 text-sm text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wider">Positive</p>
|
| 176 |
+
<div class="flex items-baseline space-x-2">
|
| 177 |
+
<h3 id="statPos" class="text-3xl font-bold text-gray-900 dark:text-white">{{ positive }}</h3>
|
| 178 |
+
<span id="pctPos" class="text-sm font-bold text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded-full">{{ pct_pos }}%</span>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
<div class="glass rounded-2xl shadow-sm p-6 hover-card">
|
| 184 |
+
<div class="flex items-center">
|
| 185 |
+
<div class="p-4 rounded-xl bg-rose-500/10 text-rose-600 dark:text-rose-400">
|
| 186 |
+
<i class="fa-solid fa-face-frown text-2xl"></i>
|
| 187 |
+
</div>
|
| 188 |
+
<div class="ml-5">
|
| 189 |
+
<p class="mb-1 text-sm text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wider">Negative</p>
|
| 190 |
+
<div class="flex items-baseline space-x-2">
|
| 191 |
+
<h3 id="statNeg" class="text-3xl font-bold text-gray-900 dark:text-white">{{ negative }}</h3>
|
| 192 |
+
<span id="pctNeg" class="text-sm font-bold text-rose-500 bg-rose-500/10 px-2 py-0.5 rounded-full">{{ pct_neg }}%</span>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
<div class="glass rounded-2xl shadow-sm p-6 hover-card">
|
| 198 |
+
<div class="flex items-center">
|
| 199 |
+
<div class="p-4 rounded-xl bg-violet-500/10 text-violet-600 dark:text-violet-400">
|
| 200 |
+
<i class="fa-solid fa-face-meh text-2xl"></i>
|
| 201 |
+
</div>
|
| 202 |
+
<div class="ml-5">
|
| 203 |
+
<p class="mb-1 text-sm text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wider">Neutral</p>
|
| 204 |
+
<div class="flex items-baseline space-x-2">
|
| 205 |
+
<h3 id="statNeu" class="text-3xl font-bold text-gray-900 dark:text-white">{{ neutral }}</h3>
|
| 206 |
+
<span id="pctNeu" class="text-sm font-bold text-violet-500 bg-violet-500/10 px-2 py-0.5 rounded-full">{{ pct_neu }}%</span>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
<!-- Charts Row -->
|
| 214 |
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8 animate-fade-in animation-delay-200">
|
| 215 |
+
<div class="glass rounded-2xl shadow-sm p-6 lg:col-span-1 border border-white/20 dark:border-gray-700/50">
|
| 216 |
+
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4 tracking-tight">Sentiment Distribution</h3>
|
| 217 |
+
<div class="relative h-64">
|
| 218 |
+
{% if total_count > 0 %}
|
| 219 |
+
<canvas id="sentimentPieChart"></canvas>
|
| 220 |
+
{% else %}
|
| 221 |
+
<div class="flex h-full items-center justify-center text-gray-400 italic">No data available</div>
|
| 222 |
+
{% endif %}
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
|
| 226 |
+
<div class="glass rounded-2xl shadow-sm p-6 lg:col-span-2 border border-white/20 dark:border-gray-700/50">
|
| 227 |
+
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4 tracking-tight">Volume by Department</h3>
|
| 228 |
+
<div class="relative h-64">
|
| 229 |
+
{% if total_count > 0 %}
|
| 230 |
+
<canvas id="deptBarChart"></canvas>
|
| 231 |
+
{% else %}
|
| 232 |
+
<div class="flex h-full items-center justify-center text-gray-400 italic">No data available</div>
|
| 233 |
+
{% endif %}
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
|
| 238 |
+
<!-- Keyword Cloud Row -->
|
| 239 |
+
<div class="glass rounded-2xl shadow-sm p-6 mb-8 border border-white/20 dark:border-gray-700/50 animate-fade-in animation-delay-250">
|
| 240 |
+
<div class="flex justify-between items-center mb-4">
|
| 241 |
+
<h3 class="text-lg font-bold text-gray-900 dark:text-white tracking-tight"><i class="fa-solid fa-cloud-word mr-2 text-indigo-500"></i>AI Trending Keywords</h3>
|
| 242 |
+
<span class="text-xs font-semibold px-2 py-1 bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400 rounded-lg">Real-time Extraction</span>
|
| 243 |
+
</div>
|
| 244 |
+
<div class="relative h-64 w-full flex items-center justify-center overflow-hidden">
|
| 245 |
+
<canvas id="keywordCloud" class="w-full h-full"></canvas>
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
|
| 249 |
+
<!-- Recent Feedback Table -->
|
| 250 |
+
<div class="glass rounded-2xl shadow-lg border border-white/20 dark:border-gray-700/50 overflow-hidden animate-fade-in animation-delay-300">
|
| 251 |
+
<div class="px-6 py-5 border-b border-gray-200/50 dark:border-gray-700/50 bg-white/30 dark:bg-gray-800/30 flex justify-between items-center backdrop-blur-md">
|
| 252 |
+
<h3 class="text-lg font-bold text-gray-900 dark:text-white tracking-tight">Recent Feedback</h3>
|
| 253 |
+
</div>
|
| 254 |
+
<div class="w-full">
|
| 255 |
+
<table id="recentFeedbackTable" class="w-full text-sm text-left text-gray-600 dark:text-gray-300">
|
| 256 |
+
<thead class="text-xs text-gray-500 uppercase bg-black/5 dark:bg-white/5 dark:text-gray-400 tracking-wider">
|
| 257 |
+
<tr>
|
| 258 |
+
<th scope="col" class="px-6 py-4 font-semibold">Department</th>
|
| 259 |
+
<th scope="col" class="px-6 py-4 font-semibold">Text Fragment</th>
|
| 260 |
+
<th scope="col" class="px-6 py-4 font-semibold">Sentiment</th>
|
| 261 |
+
<th scope="col" class="px-6 py-4 font-semibold">Score</th>
|
| 262 |
+
<th scope="col" class="px-6 py-4 font-semibold tracking-wider">Date</th>
|
| 263 |
+
</tr>
|
| 264 |
+
</thead>
|
| 265 |
+
<tbody class="divide-y divide-gray-200/50 dark:divide-gray-700/50">
|
| 266 |
+
{% for item in recent_feedback %}
|
| 267 |
+
<tr class="hover:bg-black/5 dark:hover:bg-white/5 transition-colors duration-150">
|
| 268 |
+
<td class="px-6 py-4 font-medium text-gray-900 dark:text-white whitespace-nowrap">
|
| 269 |
+
{{ item.department_category or 'N/A' }}
|
| 270 |
+
</td>
|
| 271 |
+
<td class="px-6 py-4 truncate max-w-xs text-gray-600 dark:text-gray-300" title="{{ item.original_text }}">
|
| 272 |
+
{{ item.original_text[:50] }}{% if item.original_text|length > 50 %}...{% endif %}
|
| 273 |
+
</td>
|
| 274 |
+
<td class="px-6 py-4">
|
| 275 |
+
{{ item.sentiment | sentiment_badge }}
|
| 276 |
+
</td>
|
| 277 |
+
<td class="px-6 py-4 font-mono text-xs">
|
| 278 |
+
<span class="bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 px-2 py-1 rounded-md">{{ "%.2f"|format(item.sentiment_score) }}</span>
|
| 279 |
+
</td>
|
| 280 |
+
<td class="px-6 py-4 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400">
|
| 281 |
+
{{ item.created_at.strftime('%b %d, %Y %H:%M') }}
|
| 282 |
+
</td>
|
| 283 |
+
</tr>
|
| 284 |
+
{% else %}
|
| 285 |
+
<tr>
|
| 286 |
+
<td colspan="5" class="px-6 py-10 text-center text-gray-500 italic">
|
| 287 |
+
No feedback entries found in the system yet.
|
| 288 |
+
</td>
|
| 289 |
+
</tr>
|
| 290 |
+
{% endfor %}
|
| 291 |
+
</tbody>
|
| 292 |
+
</table>
|
| 293 |
+
</div>
|
| 294 |
+
</div>
|
| 295 |
+
{% endblock %}
|
templates/dashboard/hod_staff.html
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}{{ current_user.role }} Dashboard - NeuroSent{% endblock %}
|
| 3 |
+
|
| 4 |
+
{% block extra_scripts %}
|
| 5 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
| 6 |
+
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
|
| 7 |
+
<script>
|
| 8 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 9 |
+
renderSentimentPieChart('sentimentPieChart', {
|
| 10 |
+
positive: {{ positive|default(0) }},
|
| 11 |
+
negative: {{ negative|default(0) }},
|
| 12 |
+
neutral: {{ neutral|default(0) }}
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
// Date Range Filter Logic
|
| 16 |
+
document.getElementById('applyDateFilter').addEventListener('click', function() {
|
| 17 |
+
const startDate = document.getElementById('startDate').value;
|
| 18 |
+
const endDate = document.getElementById('endDate').value;
|
| 19 |
+
const btn = this;
|
| 20 |
+
|
| 21 |
+
if(!startDate && !endDate) return;
|
| 22 |
+
|
| 23 |
+
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
|
| 24 |
+
|
| 25 |
+
fetch(`{{ url_for("dashboard.get_dashboard_data") }}?start_date=${startDate}&end_date=${endDate}`)
|
| 26 |
+
.then(res => res.json())
|
| 27 |
+
.then(data => {
|
| 28 |
+
btn.innerHTML = '<i class="fa-solid fa-filter"></i> Apply';
|
| 29 |
+
if(data.success) {
|
| 30 |
+
document.getElementById('statTotal').innerText = data.total_count;
|
| 31 |
+
document.getElementById('statPos').innerText = data.positive;
|
| 32 |
+
document.getElementById('statNeg').innerText = data.negative;
|
| 33 |
+
document.getElementById('statNeu').innerText = data.neutral;
|
| 34 |
+
|
| 35 |
+
document.getElementById('pctPos').innerText = data.pct_pos + '%';
|
| 36 |
+
document.getElementById('pctNeg').innerText = data.pct_neg + '%';
|
| 37 |
+
document.getElementById('pctNeu').innerText = data.pct_neu + '%';
|
| 38 |
+
|
| 39 |
+
const pieChart = Chart.instances[0];
|
| 40 |
+
if(pieChart) {
|
| 41 |
+
pieChart.data.datasets[0].data = [data.positive, data.negative, data.neutral];
|
| 42 |
+
pieChart.update();
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
})
|
| 46 |
+
.catch(err => {
|
| 47 |
+
btn.innerHTML = '<i class="fa-solid fa-filter"></i> Apply';
|
| 48 |
+
console.error("Date filter error:", err);
|
| 49 |
+
});
|
| 50 |
+
});
|
| 51 |
+
});
|
| 52 |
+
</script>
|
| 53 |
+
{% endblock %}
|
| 54 |
+
|
| 55 |
+
{% block content %}
|
| 56 |
+
<div class="mb-8 flex flex-col md:flex-row justify-between items-start md:items-end animate-fade-in">
|
| 57 |
+
<div>
|
| 58 |
+
<h1 class="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">{{ current_user.department or 'Your' }} Department Dashboard</h1>
|
| 59 |
+
<p class="text-gray-500 dark:text-gray-400 text-sm mt-2">Viewing analytics for {{ current_user.department or 'your specific scope' }}.</p>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
<div class="mt-4 md:mt-0 flex flex-col sm:flex-row space-y-3 sm:space-y-0 sm:space-x-3 items-end">
|
| 63 |
+
<!-- Date Filter UI -->
|
| 64 |
+
<div class="flex items-center space-x-2 bg-white/20 dark:bg-gray-800/40 p-1.5 rounded-xl border border-white/20 dark:border-gray-700/50 backdrop-blur-md">
|
| 65 |
+
<input type="date" id="startDate" class="bg-transparent border-none text-xs text-gray-700 dark:text-gray-300 focus:ring-0 cursor-pointer" title="Start Date">
|
| 66 |
+
<span class="text-gray-400 text-xs">to</span>
|
| 67 |
+
<input type="date" id="endDate" class="bg-transparent border-none text-xs text-gray-700 dark:text-gray-300 focus:ring-0 cursor-pointer" title="End Date">
|
| 68 |
+
<button id="applyDateFilter" class="px-3 py-1.5 text-xs font-bold text-white bg-primary-500 hover:bg-primary-600 rounded-lg transition-colors shadow-sm">
|
| 69 |
+
<i class="fa-solid fa-filter"></i> Apply
|
| 70 |
+
</button>
|
| 71 |
+
</div>
|
| 72 |
+
<a href="{{ url_for('reports.download_pdf') }}" class="px-5 py-2.5 text-sm font-semibold text-primary-700 bg-primary-100/80 hover:bg-primary-200 dark:bg-primary-500/20 dark:text-primary-300 dark:hover:bg-primary-500/30 rounded-xl transition-all shadow-sm hover:shadow">
|
| 73 |
+
<i class="fa-solid fa-download mr-2"></i> Export Report
|
| 74 |
+
</a>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<!-- Stats Overview -->
|
| 79 |
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 animate-fade-in animation-delay-100">
|
| 80 |
+
<div class="glass rounded-2xl shadow-sm p-6 hover-card">
|
| 81 |
+
<div class="flex items-center">
|
| 82 |
+
<div class="p-4 rounded-xl bg-blue-500/10 text-blue-600 dark:text-blue-400"><i class="fa-solid fa-filter text-2xl"></i></div>
|
| 83 |
+
<div class="ml-5">
|
| 84 |
+
<p class="mb-1 text-sm text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wider">Total Feedback</p>
|
| 85 |
+
<h3 id="statTotal" class="text-3xl font-bold text-gray-900 dark:text-white">{{ total_count }}</h3>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
<div class="glass rounded-2xl shadow-sm p-6 hover-card">
|
| 90 |
+
<div class="flex items-center">
|
| 91 |
+
<div class="p-4 rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"><i class="fa-solid fa-thumbs-up text-2xl"></i></div>
|
| 92 |
+
<div class="ml-5">
|
| 93 |
+
<p class="mb-1 text-sm text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wider">Positive</p>
|
| 94 |
+
<div class="flex items-baseline space-x-2">
|
| 95 |
+
<h3 id="statPos" class="text-3xl font-bold text-gray-900 dark:text-white">{{ positive }}</h3>
|
| 96 |
+
<span id="pctPos" class="text-sm font-bold text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded-full">{{ pct_pos }}%</span>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
<div class="glass rounded-2xl shadow-sm p-6 hover-card">
|
| 102 |
+
<div class="flex items-center">
|
| 103 |
+
<div class="p-4 rounded-xl bg-rose-500/10 text-rose-600 dark:text-rose-400"><i class="fa-solid fa-thumbs-down text-2xl"></i></div>
|
| 104 |
+
<div class="ml-5">
|
| 105 |
+
<p class="mb-1 text-sm text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wider">Negative</p>
|
| 106 |
+
<div class="flex items-baseline space-x-2">
|
| 107 |
+
<h3 id="statNeg" class="text-3xl font-bold text-gray-900 dark:text-white">{{ negative }}</h3>
|
| 108 |
+
<span id="pctNeg" class="text-sm font-bold text-rose-500 bg-rose-500/10 px-2 py-0.5 rounded-full">{{ pct_neg }}%</span>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
<div class="glass rounded-2xl shadow-sm p-6 hover-card">
|
| 114 |
+
<div class="flex items-center">
|
| 115 |
+
<div class="p-4 rounded-xl bg-violet-500/10 text-violet-600 dark:text-violet-400"><i class="fa-solid fa-minus text-2xl"></i></div>
|
| 116 |
+
<div class="ml-5">
|
| 117 |
+
<p class="mb-1 text-sm text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wider">Neutral</p>
|
| 118 |
+
<div class="flex items-baseline space-x-2">
|
| 119 |
+
<h3 id="statNeu" class="text-3xl font-bold text-gray-900 dark:text-white">{{ neutral }}</h3>
|
| 120 |
+
<span id="pctNeu" class="text-sm font-bold text-violet-500 bg-violet-500/10 px-2 py-0.5 rounded-full">{{ pct_neu }}%</span>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8 animate-fade-in animation-delay-200">
|
| 128 |
+
<div class="glass rounded-2xl shadow-sm p-6 lg:col-span-1 border border-white/20 dark:border-gray-700/50">
|
| 129 |
+
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4 tracking-tight">Sentiment Distribution</h3>
|
| 130 |
+
<div class="relative h-64">
|
| 131 |
+
{% if total_count > 0 %}
|
| 132 |
+
<canvas id="sentimentPieChart"></canvas>
|
| 133 |
+
{% else %}
|
| 134 |
+
<div class="flex h-full items-center justify-center text-gray-400 italic">No data available</div>
|
| 135 |
+
{% endif %}
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
<div class="glass rounded-2xl shadow-sm p-6 lg:col-span-2 border border-white/20 dark:border-gray-700/50 flex flex-col">
|
| 139 |
+
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4 tracking-tight">Recent Comments</h3>
|
| 140 |
+
<div class="flex-1 overflow-y-auto pr-2 space-y-4">
|
| 141 |
+
{% for item in recent_feedback %}
|
| 142 |
+
<div class="p-5 rounded-xl bg-white/40 dark:bg-gray-800/40 border border-gray-100/50 dark:border-gray-600/30 shadow-sm hover:shadow-md transition-shadow">
|
| 143 |
+
<div class="flex justify-between items-start mb-3">
|
| 144 |
+
<div class="flex items-center space-x-3">
|
| 145 |
+
{{ item.sentiment | sentiment_badge }}
|
| 146 |
+
<span class="text-xs font-medium text-gray-500">{{ item.created_at.strftime('%b %d, %Y %H:%M') }}</span>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
<p class="text-gray-800 dark:text-gray-200 text-sm leading-relaxed tracking-wide">"{{ item.original_text }}"</p>
|
| 150 |
+
</div>
|
| 151 |
+
{% else %}
|
| 152 |
+
<div class="flex h-full items-center justify-center py-8">
|
| 153 |
+
<p class="text-gray-500 italic">No feedback recorded yet in this department.</p>
|
| 154 |
+
</div>
|
| 155 |
+
{% endfor %}
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
{% endblock %}
|
templates/dashboard/profile.html
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}My Profile - NeuroSent{% endblock %}
|
| 3 |
+
|
| 4 |
+
{% block content %}
|
| 5 |
+
<div class="max-w-4xl mx-auto pt-4 animate-fade-in relative z-10">
|
| 6 |
+
<div class="mb-8 flex items-center justify-between">
|
| 7 |
+
<div>
|
| 8 |
+
<h1 class="text-3xl sm:text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-indigo-600 dark:from-primary-400 dark:to-indigo-400 tracking-tight drop-shadow-sm">My Profile</h1>
|
| 9 |
+
<p class="text-gray-600 dark:text-gray-300 font-medium text-sm mt-1">Manage your account and view key statistics.</p>
|
| 10 |
+
</div>
|
| 11 |
+
</div>
|
| 12 |
+
|
| 13 |
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
| 14 |
+
|
| 15 |
+
<!-- Left Column: User Identity Card -->
|
| 16 |
+
<div class="md:col-span-1">
|
| 17 |
+
<div class="glass rounded-3xl shadow-xl p-8 border border-white/40 dark:border-gray-700/60 backdrop-blur-2xl relative overflow-hidden transition-all hover:shadow-2xl text-center h-full flex flex-col items-center justify-center">
|
| 18 |
+
<!-- Decorative Orb -->
|
| 19 |
+
<div class="absolute -top-10 -left-10 w-32 h-32 bg-primary-400/20 dark:bg-primary-500/10 rounded-full blur-3xl pointer-events-none"></div>
|
| 20 |
+
|
| 21 |
+
<div class="w-32 h-32 mb-6 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-black text-5xl shadow-inner ring-4 ring-white/30 dark:ring-gray-800/50 transform transition hover:scale-105 relative z-10">
|
| 22 |
+
{{ current_user.name[0] }}
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1 relative z-10">{{ current_user.name }}</h2>
|
| 26 |
+
|
| 27 |
+
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold leading-5 bg-primary-100 text-primary-800 dark:bg-primary-900/40 dark:text-primary-300 mb-4 relative z-10 border border-primary-200 dark:border-primary-800/60">
|
| 28 |
+
<i class="fa-solid fa-shield-halved mr-1.5"></i> {{ current_user.role }}
|
| 29 |
+
</span>
|
| 30 |
+
|
| 31 |
+
<p class="text-gray-500 dark:text-gray-400 text-sm font-medium relative z-10 flex items-center justify-center gap-2">
|
| 32 |
+
<i class="fa-solid fa-building text-gray-400"></i>
|
| 33 |
+
{{ current_user.department if current_user.department else 'No Department Assigned' }}
|
| 34 |
+
</p>
|
| 35 |
+
|
| 36 |
+
<div class="mt-8 pt-6 border-t border-gray-200/50 dark:border-gray-700/50 w-full relative z-10">
|
| 37 |
+
<p class="text-xs text-gray-500 dark:text-gray-400 font-medium">Joined NeuroSent on</p>
|
| 38 |
+
<p class="text-sm text-gray-800 dark:text-gray-200 font-bold mt-1">{{ current_user.created_at.strftime('%B %d, %Y') }}</p>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<!-- Right Column: Settings & Stats -->
|
| 44 |
+
<div class="md:col-span-2 space-y-8">
|
| 45 |
+
|
| 46 |
+
<!-- Statistics Card -->
|
| 47 |
+
<div class="glass rounded-3xl shadow-xl p-8 border border-white/40 dark:border-gray-700/60 backdrop-blur-2xl relative overflow-hidden transition-all hover:shadow-2xl">
|
| 48 |
+
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center">
|
| 49 |
+
<i class="fa-solid fa-chart-pie mr-3 text-primary-500"></i> Activity Overview
|
| 50 |
+
</h3>
|
| 51 |
+
|
| 52 |
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
| 53 |
+
<div class="bg-white/40 dark:bg-gray-800/40 rounded-2xl p-6 border border-white/50 dark:border-gray-700/50 shadow-sm flex items-center gap-5 transition hover:bg-white/60 dark:hover:bg-gray-800/60">
|
| 54 |
+
<div class="w-14 h-14 rounded-full bg-blue-100 dark:bg-blue-900/40 flex items-center justify-center text-blue-600 dark:text-blue-400 text-xl shadow-inner">
|
| 55 |
+
<i class="fa-solid fa-comment-dots"></i>
|
| 56 |
+
</div>
|
| 57 |
+
<div>
|
| 58 |
+
<p class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Total Feedbacks</p>
|
| 59 |
+
<p class="text-3xl font-black text-gray-900 dark:text-white mt-1">{{ total_feedbacks }}</p>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
<div class="bg-white/40 dark:bg-gray-800/40 rounded-2xl p-6 border border-white/50 dark:border-gray-700/50 shadow-sm flex items-center gap-5 transition hover:bg-white/60 dark:hover:bg-gray-800/60">
|
| 64 |
+
<div class="w-14 h-14 rounded-full {% if avg_score > 0 %}bg-green-100 dark:bg-green-900/40 text-green-600 dark:text-green-400{% elif avg_score < 0 %}bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400{% else %}bg-gray-100 dark:bg-gray-700/40 text-gray-600 dark:text-gray-400{% endif %} flex items-center justify-center text-xl shadow-inner">
|
| 65 |
+
<i class="fa-solid fa-scale-balanced"></i>
|
| 66 |
+
</div>
|
| 67 |
+
<div>
|
| 68 |
+
<p class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Avg Sentiment</p>
|
| 69 |
+
<p class="text-3xl font-black text-gray-900 dark:text-white mt-1">{{ avg_score }}</p>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<!-- Account Details Form (Read-only for now) -->
|
| 76 |
+
<div class="glass rounded-3xl shadow-xl p-8 border border-white/40 dark:border-gray-700/60 backdrop-blur-2xl relative overflow-hidden transition-all hover:shadow-2xl">
|
| 77 |
+
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center">
|
| 78 |
+
<i class="fa-solid fa-id-card flex-shrink-0 mr-3 text-primary-500"></i> Account Details
|
| 79 |
+
</h3>
|
| 80 |
+
|
| 81 |
+
<div class="space-y-5">
|
| 82 |
+
<div>
|
| 83 |
+
<label class="block text-sm font-bold text-gray-800 dark:text-gray-200 uppercase tracking-wide mb-2">Email Address</label>
|
| 84 |
+
<div class="relative">
|
| 85 |
+
<div class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none text-gray-400">
|
| 86 |
+
<i class="fa-solid fa-envelope"></i>
|
| 87 |
+
</div>
|
| 88 |
+
<input type="email" value="{{ current_user.email }}" readonly class="bg-gray-100/50 border border-gray-300/30 text-gray-500 rounded-xl block w-full pl-11 p-3.5 dark:bg-gray-800/40 dark:border-gray-700/30 dark:text-gray-400 shadow-inner cursor-not-allowed font-medium">
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div>
|
| 93 |
+
<label class="block text-sm font-bold text-gray-800 dark:text-gray-200 uppercase tracking-wide mb-2">System Passkey</label>
|
| 94 |
+
<div class="relative">
|
| 95 |
+
<div class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none text-gray-400">
|
| 96 |
+
<i class="fa-solid fa-lock"></i>
|
| 97 |
+
</div>
|
| 98 |
+
<!-- Display a fake masked password since we hash them -->
|
| 99 |
+
<input type="password" value="••••••••••••••" readonly class="bg-gray-100/50 border border-gray-300/30 text-gray-500 rounded-xl block w-full pl-11 p-3.5 dark:bg-gray-800/40 dark:border-gray-700/30 dark:text-gray-400 shadow-inner cursor-not-allowed tracking-widest">
|
| 100 |
+
</div>
|
| 101 |
+
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2 italic"><i class="fa-solid fa-circle-info mr-1"></i> Passwords are one-way hashed and cannot be viewed. Contact administration to execute a password reset.</p>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
{% endblock %}
|
templates/dashboard/student.html
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}My Feedback - NeuroSent{% endblock %}
|
| 3 |
+
|
| 4 |
+
{% block content %}
|
| 5 |
+
<div class="mb-8 flex flex-col md:flex-row justify-between items-start md:items-end animate-fade-in">
|
| 6 |
+
<div>
|
| 7 |
+
<h1 class="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">My Submissions</h1>
|
| 8 |
+
<p class="text-gray-500 dark:text-gray-400 text-sm mt-2">History of your submitted feedback and its AI analysis.</p>
|
| 9 |
+
</div>
|
| 10 |
+
<div class="mt-4 md:mt-0">
|
| 11 |
+
<a href="{{ url_for('feedback.submit') }}" class="px-5 py-2.5 text-sm font-semibold text-white bg-gradient-to-r from-primary-600 to-indigo-600 rounded-xl hover:from-primary-700 hover:to-indigo-700 transition-all shadow-md hover:shadow-lg transform hover:-translate-y-0.5 inline-flex items-center">
|
| 12 |
+
<i class="fa-solid fa-plus mr-2"></i> New Feedback
|
| 13 |
+
</a>
|
| 14 |
+
</div>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<!-- Stats -->
|
| 18 |
+
<div class="flex space-x-6 mb-8 animate-fade-in animation-delay-100">
|
| 19 |
+
<div class="glass px-6 py-5 rounded-2xl shadow-sm hover-card border border-white/20 dark:border-gray-700/50 min-w-[200px]">
|
| 20 |
+
<div class="flex items-center space-x-4">
|
| 21 |
+
<div class="p-3 rounded-xl bg-primary-500/10 text-primary-600 dark:text-primary-400">
|
| 22 |
+
<i class="fa-solid fa-file-lines text-2xl"></i>
|
| 23 |
+
</div>
|
| 24 |
+
<div>
|
| 25 |
+
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider block mb-1">Total Submitted</span>
|
| 26 |
+
<div class="text-3xl font-bold text-gray-900 dark:text-white">{{ total_count }}</div>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
|
| 32 |
+
<div class="glass rounded-2xl shadow-lg border border-white/20 dark:border-gray-700/50 overflow-hidden animate-fade-in animation-delay-200">
|
| 33 |
+
<div class="px-6 py-5 border-b border-gray-200/50 dark:border-gray-700/50 bg-white/30 dark:bg-gray-800/30 flex justify-between items-center backdrop-blur-md">
|
| 34 |
+
<h3 class="text-lg font-bold text-gray-900 dark:text-white tracking-tight">Submission History</h3>
|
| 35 |
+
</div>
|
| 36 |
+
{% if total_count > 0 %}
|
| 37 |
+
<div class="overflow-x-auto">
|
| 38 |
+
<table class="w-full text-sm text-left text-gray-600 dark:text-gray-300">
|
| 39 |
+
<thead class="text-xs text-gray-500 uppercase bg-black/5 dark:bg-white/5 dark:text-gray-400 tracking-wider">
|
| 40 |
+
<tr>
|
| 41 |
+
<th scope="col" class="px-6 py-4 font-semibold tracking-wider">Date</th>
|
| 42 |
+
<th scope="col" class="px-6 py-4 font-semibold w-1/2">Your Feedback</th>
|
| 43 |
+
<th scope="col" class="px-6 py-4 font-semibold">AI Sentiment</th>
|
| 44 |
+
</tr>
|
| 45 |
+
</thead>
|
| 46 |
+
<tbody class="divide-y divide-gray-200/50 dark:divide-gray-700/50">
|
| 47 |
+
{% for item in recent_feedback %}
|
| 48 |
+
<tr class="hover:bg-black/5 dark:hover:bg-white/5 transition-colors duration-150">
|
| 49 |
+
<td class="px-6 py-4 whitespace-nowrap text-xs font-medium text-gray-500 dark:text-gray-400">
|
| 50 |
+
{{ item.created_at.strftime('%b %d, %Y %H:%M') }}
|
| 51 |
+
</td>
|
| 52 |
+
<td class="px-6 py-4 text-gray-800 dark:text-gray-200 leading-relaxed tracking-wide">
|
| 53 |
+
{{ item.original_text }}
|
| 54 |
+
</td>
|
| 55 |
+
<td class="px-6 py-4">
|
| 56 |
+
{{ item.sentiment | sentiment_badge }}
|
| 57 |
+
</td>
|
| 58 |
+
</tr>
|
| 59 |
+
{% endfor %}
|
| 60 |
+
</tbody>
|
| 61 |
+
</table>
|
| 62 |
+
</div>
|
| 63 |
+
{% else %}
|
| 64 |
+
<div class="text-center py-16">
|
| 65 |
+
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-50 dark:bg-gray-800/50 mb-6 shadow-inner ring-4 ring-white/50 dark:ring-gray-700/50">
|
| 66 |
+
<i class="fa-solid fa-folder-open text-3xl text-primary-300 dark:text-gray-500 transform transition-transform hover:scale-110"></i>
|
| 67 |
+
</div>
|
| 68 |
+
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2 tracking-tight">No feedback yet</h3>
|
| 69 |
+
<p class="text-gray-500 dark:text-gray-400 max-w-sm mx-auto">You haven't submitted any feedback manually. Start by sharing your thoughts.</p>
|
| 70 |
+
<div class="mt-8">
|
| 71 |
+
<a href="{{ url_for('feedback.submit') }}" class="inline-flex items-center px-6 py-3 text-sm font-medium text-white bg-gradient-to-r from-primary-600 to-indigo-600 rounded-xl hover:from-primary-700 hover:to-indigo-700 transition-all shadow hover:shadow-lg transform hover:-translate-y-0.5">
|
| 72 |
+
Submit your first feedback <i class="fa-solid fa-arrow-right ml-2"></i>
|
| 73 |
+
</a>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
{% endif %}
|
| 77 |
+
</div>
|
| 78 |
+
{% endblock %}
|
templates/feedback/submit.html
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Submit Feedback - NeuroSent{% endblock %}
|
| 3 |
+
|
| 4 |
+
{% block content %}
|
| 5 |
+
<div class="mb-8 animate-fade-in">
|
| 6 |
+
<h1 class="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">Submit Feedback</h1>
|
| 7 |
+
<p class="text-gray-500 dark:text-gray-400 text-sm mt-2">Share your thoughts on products, services, or general experience.</p>
|
| 8 |
+
</div>
|
| 9 |
+
|
| 10 |
+
<div class="max-w-3xl mx-auto animate-fade-in animation-delay-100">
|
| 11 |
+
<div class="glass rounded-2xl shadow-xl p-8 sm:p-10 hover-card border border-white/20 dark:border-gray-700/50 relative overflow-hidden">
|
| 12 |
+
<!-- Decorative glowing orb -->
|
| 13 |
+
<div class="absolute -top-16 -right-16 w-32 h-32 bg-primary-500/20 rounded-full blur-2xl pointer-events-none"></div>
|
| 14 |
+
|
| 15 |
+
<form action="{{ url_for('feedback.submit') }}" method="POST" class="space-y-7 relative z-10">
|
| 16 |
+
|
| 17 |
+
{% if current_user.role in ['Admin', 'HOD', 'Staff'] %}
|
| 18 |
+
<div>
|
| 19 |
+
<label for="department" class="block mb-2 text-sm font-semibold text-gray-900 dark:text-gray-300">Department / Category</label>
|
| 20 |
+
<div class="relative">
|
| 21 |
+
<div class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
|
| 22 |
+
<i class="fa-solid fa-building text-primary-400"></i>
|
| 23 |
+
</div>
|
| 24 |
+
<input type="text" id="department" name="department" value="{{ current_user.department }}" class="bg-white/60 border border-gray-300/50 text-gray-900 text-sm rounded-xl focus:ring-primary-500 focus:border-primary-500 block w-full pl-11 p-3.5 dark:bg-gray-700/60 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white shadow-sm transition-all focus:bg-white dark:focus:bg-gray-800" placeholder="e.g. Facilities, IT Support, etc.">
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
{% endif %}
|
| 28 |
+
|
| 29 |
+
<div>
|
| 30 |
+
<label for="text" class="block mb-2 text-sm font-semibold text-gray-900 dark:text-gray-300">Your Feedback</label>
|
| 31 |
+
<textarea id="text" name="text" rows="6" class="block p-4 w-full text-sm text-gray-900 bg-white/60 rounded-xl border border-gray-300/50 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700/60 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500 transition-all shadow-sm focus:bg-white dark:focus:bg-gray-800" placeholder="Write your comments here... Please be as detailed as possible." required></textarea>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<div class="p-4 mb-4 text-sm text-indigo-800 rounded-xl bg-indigo-50/80 dark:bg-indigo-900/30 dark:text-indigo-300 border border-indigo-200/50 dark:border-indigo-800/50 shadow-sm backdrop-blur-sm" role="alert">
|
| 35 |
+
<div class="flex items-center font-bold mb-1 tracking-wide">
|
| 36 |
+
<i class="fa-solid fa-robot mr-2 text-indigo-500 dark:text-indigo-400"></i> AI Processing
|
| 37 |
+
</div>
|
| 38 |
+
Your feedback will be automatically analyzed by our advanced NLP sentiment engine once submitted.
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<button type="submit" class="w-full text-white bg-gradient-to-r from-primary-600 to-indigo-600 hover:from-primary-700 hover:to-indigo-700 focus:ring-4 focus:ring-primary-300 font-bold rounded-xl text-sm px-5 py-3.5 text-center dark:focus:ring-primary-900 transition-all shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 inline-flex items-center justify-center">
|
| 42 |
+
<i class="fa-solid fa-paper-plane mr-2 text-lg"></i> Submit Feedback
|
| 43 |
+
</button>
|
| 44 |
+
</form>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
{% endblock %}
|
templates/upload/index.html
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Bulk Upload - NeuroSent{% endblock %}
|
| 3 |
+
|
| 4 |
+
{% block content %}
|
| 5 |
+
<div class="mb-8 animate-fade-in">
|
| 6 |
+
<h1 class="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">Dataset Upload</h1>
|
| 7 |
+
<p class="text-gray-500 dark:text-gray-400 text-sm mt-2">Upload CSV or Excel files containing feedback for bulk sentiment processing.</p>
|
| 8 |
+
</div>
|
| 9 |
+
|
| 10 |
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 11 |
+
|
| 12 |
+
<!-- Upload Form -->
|
| 13 |
+
<div class="lg:col-span-1 animate-fade-in animation-delay-100">
|
| 14 |
+
<div class="glass rounded-2xl shadow-lg p-6 hover-card border border-white/20 dark:border-gray-700/50">
|
| 15 |
+
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-5 tracking-tight">Upload File</h3>
|
| 16 |
+
|
| 17 |
+
<form action="{{ url_for('upload.index') }}" method="POST" enctype="multipart/form-data" class="space-y-5 shadow-inner border border-gray-100/50 dark:border-gray-700/50 p-5 rounded-xl bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm">
|
| 18 |
+
<div class="flex items-center justify-center w-full">
|
| 19 |
+
<label for="dataset" class="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-primary-300 dark:border-primary-500/30 rounded-xl cursor-pointer bg-white/50 dark:hover:bg-gray-800/60 dark:bg-gray-700/40 hover:bg-primary-50 hover:border-primary-400 transition-all duration-300 group">
|
| 20 |
+
<div class="flex flex-col items-center justify-center pt-5 pb-6 transform group-hover:-translate-y-1 transition-transform">
|
| 21 |
+
<i class="fa-solid fa-cloud-arrow-up text-5xl text-primary-400 group-hover:text-primary-500 mb-4 transition-colors"></i>
|
| 22 |
+
<p class="mb-2 text-sm text-gray-600 dark:text-gray-300"><span class="font-bold text-primary-600 dark:text-primary-400">Click to upload</span> or drag and drop</p>
|
| 23 |
+
<p class="text-xs text-gray-500 dark:text-gray-400">CSV, XLS, or XLSX (MAX. 16MB)</p>
|
| 24 |
+
</div>
|
| 25 |
+
<input id="dataset" name="dataset" type="file" class="hidden" accept=".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel" required />
|
| 26 |
+
</label>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<div id="file-name-display" class="hidden text-sm text-center font-medium text-primary-700 dark:text-primary-300 bg-primary-100/50 dark:bg-primary-900/40 p-3 rounded-lg border border-primary-200/50 dark:border-primary-800/50 truncate shadow-sm"></div>
|
| 30 |
+
|
| 31 |
+
<div>
|
| 32 |
+
<label for="text_column" class="block mb-2 text-sm font-semibold text-gray-900 dark:text-gray-300">Target Text Column <span class="text-gray-400 font-normal">(Optional)</span></label>
|
| 33 |
+
<input type="text" id="text_column" name="text_column" class="bg-white/70 border border-gray-300/50 text-gray-900 text-sm rounded-xl focus:ring-primary-500 focus:border-primary-500 block w-full p-3 dark:bg-gray-700/70 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white shadow-sm transition-all" placeholder="e.g. 'review_text'">
|
| 34 |
+
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400 italic">Leave blank to auto-detect the text column.</p>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<button type="submit" id="upload-btn" class="w-full text-white bg-gradient-to-r from-primary-600 to-indigo-600 hover:from-primary-700 hover:to-indigo-700 focus:ring-4 focus:ring-primary-300 font-semibold rounded-xl text-sm px-5 py-3 text-center dark:focus:ring-primary-800 transition-all shadow-md hover:shadow-lg transform hover:-translate-y-0.5 flex justify-center items-center">
|
| 38 |
+
<i class="fa-solid fa-gears mr-2"></i> Process Dataset
|
| 39 |
+
</button>
|
| 40 |
+
</form>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<!-- Upload History -->
|
| 45 |
+
<div class="lg:col-span-2 animate-fade-in animation-delay-200">
|
| 46 |
+
<div class="glass rounded-2xl shadow-lg border border-white/20 dark:border-gray-700/50 overflow-hidden">
|
| 47 |
+
<div class="px-6 py-5 border-b border-gray-200/50 dark:border-gray-700/50 bg-white/30 dark:bg-gray-800/30 flex justify-between items-center backdrop-blur-md">
|
| 48 |
+
<h3 class="text-lg font-bold text-gray-900 dark:text-white tracking-tight">Upload History</h3>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<div class="overflow-x-auto">
|
| 52 |
+
<table class="w-full text-sm text-left text-gray-600 dark:text-gray-300">
|
| 53 |
+
<thead class="text-xs text-gray-500 uppercase bg-black/5 dark:bg-white/5 dark:text-gray-400 tracking-wider">
|
| 54 |
+
<tr>
|
| 55 |
+
<th scope="col" class="px-6 py-4 font-semibold">Filename</th>
|
| 56 |
+
<th scope="col" class="px-6 py-4 font-semibold">Status</th>
|
| 57 |
+
<th scope="col" class="px-6 py-4 font-semibold">Processed</th>
|
| 58 |
+
<th scope="col" class="px-6 py-4 font-semibold tracking-wider">Date</th>
|
| 59 |
+
<th scope="col" class="px-6 py-4 font-semibold text-right">Actions</th>
|
| 60 |
+
</tr>
|
| 61 |
+
</thead>
|
| 62 |
+
<tbody class="divide-y divide-gray-200/50 dark:divide-gray-700/50">
|
| 63 |
+
{% for upload in uploads %}
|
| 64 |
+
<tr class="hover:bg-black/5 dark:hover:bg-white/5 transition-colors duration-150">
|
| 65 |
+
<td class="px-6 py-4 font-medium text-gray-900 dark:text-white whitespace-nowrap">
|
| 66 |
+
<i class="fa-solid fa-file-csv text-primary-400 mr-2 text-lg"></i> {{ upload.filename }}
|
| 67 |
+
</td>
|
| 68 |
+
<td class="px-6 py-4">
|
| 69 |
+
{% if upload.status == 'Completed' %}
|
| 70 |
+
<span class="bg-emerald-100/80 text-emerald-800 border border-emerald-200 dark:border-emerald-800/50 text-xs font-semibold px-2.5 py-1 rounded-md dark:bg-emerald-900/50 dark:text-emerald-300 shadow-sm">Completed</span>
|
| 71 |
+
{% elif upload.status == 'Failed' %}
|
| 72 |
+
<span class="bg-rose-100/80 text-rose-800 border border-rose-200 dark:border-rose-800/50 text-xs font-semibold px-2.5 py-1 rounded-md dark:bg-rose-900/50 dark:text-rose-300 shadow-sm">Failed</span>
|
| 73 |
+
{% else %}
|
| 74 |
+
<span class="bg-amber-100/80 text-amber-800 border border-amber-200 dark:border-amber-800/50 text-xs font-semibold px-2.5 py-1 rounded-md dark:bg-amber-900/50 dark:text-amber-300 shadow-sm">
|
| 75 |
+
<i class="fa-solid fa-circle-notch fa-spin mr-1"></i> Processing
|
| 76 |
+
</span>
|
| 77 |
+
{% endif %}
|
| 78 |
+
</td>
|
| 79 |
+
<td class="px-6 py-4 font-mono text-xs">
|
| 80 |
+
<span class="bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 px-2 py-1 rounded-md">{{ upload.processed_rows }} / {{ upload.total_rows }}</span>
|
| 81 |
+
</td>
|
| 82 |
+
<td class="px-6 py-4 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400">
|
| 83 |
+
{{ upload.upload_date.strftime('%b %d, %Y %H:%M') }}
|
| 84 |
+
</td>
|
| 85 |
+
<td class="px-6 py-4 text-right">
|
| 86 |
+
{% if current_user.role == 'Admin' %}
|
| 87 |
+
<form action="{{ url_for('upload.delete_upload', upload_id=upload.id) }}" method="POST" class="inline" onsubmit="return confirm('Are you sure you want to delete this upload and ALL its associated feedback records?');">
|
| 88 |
+
<button type="submit" class="text-rose-500 hover:text-rose-700 dark:text-rose-400 dark:hover:text-rose-300 p-2 hover:bg-rose-50 dark:hover:bg-rose-500/10 rounded-lg transition-colors" title="Delete Upload">
|
| 89 |
+
<i class="fa-solid fa-trash-can text-lg"></i>
|
| 90 |
+
</button>
|
| 91 |
+
</form>
|
| 92 |
+
{% endif %}
|
| 93 |
+
</td>
|
| 94 |
+
</tr>
|
| 95 |
+
{% else %}
|
| 96 |
+
<tr>
|
| 97 |
+
<td colspan="5" class="px-6 py-12 text-center text-gray-500">
|
| 98 |
+
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 mb-4 shadow-inner">
|
| 99 |
+
<i class="fa-solid fa-inbox text-2xl text-gray-400"></i>
|
| 100 |
+
</div>
|
| 101 |
+
<p class="italic">No dataset uploads found.</p>
|
| 102 |
+
</td>
|
| 103 |
+
</tr>
|
| 104 |
+
{% endfor %}
|
| 105 |
+
</tbody>
|
| 106 |
+
</table>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
{% endblock %}
|
| 113 |
+
|
| 114 |
+
{% block extra_scripts %}
|
| 115 |
+
<script>
|
| 116 |
+
const fileInput = document.getElementById('dataset');
|
| 117 |
+
const fileNameDisplay = document.getElementById('file-name-display');
|
| 118 |
+
const uploadBtn = document.getElementById('upload-btn');
|
| 119 |
+
|
| 120 |
+
fileInput.addEventListener('change', function(e) {
|
| 121 |
+
if(e.target.files.length > 0) {
|
| 122 |
+
fileNameDisplay.textContent = 'Selected: ' + e.target.files[0].name;
|
| 123 |
+
fileNameDisplay.classList.remove('hidden');
|
| 124 |
+
}
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
// Optional: show loading state on form submit
|
| 128 |
+
const form = document.querySelector('form');
|
| 129 |
+
form.addEventListener('submit', function() {
|
| 130 |
+
if(fileInput.files.length > 0) {
|
| 131 |
+
uploadBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i>Processing...';
|
| 132 |
+
uploadBtn.disabled = true;
|
| 133 |
+
uploadBtn.classList.add('opacity-75');
|
| 134 |
+
// Form submission will continue normally
|
| 135 |
+
}
|
| 136 |
+
});
|
| 137 |
+
</script>
|
| 138 |
+
{% endblock %}
|
test_app.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from app import create_app
|
| 3 |
+
from models import db, User
|
| 4 |
+
|
| 5 |
+
@pytest.fixture
|
| 6 |
+
def test_client():
|
| 7 |
+
# Configure app for testing
|
| 8 |
+
app = create_app()
|
| 9 |
+
app.config['TESTING'] = True
|
| 10 |
+
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' # Use in-memory SQLite for tests
|
| 11 |
+
app.config['WTF_CSRF_ENABLED'] = False
|
| 12 |
+
|
| 13 |
+
with app.test_client() as testing_client:
|
| 14 |
+
with app.app_context():
|
| 15 |
+
db.create_all()
|
| 16 |
+
|
| 17 |
+
# Seed test users
|
| 18 |
+
admin = User(name="Admin test", email="admin@test.com", role="Admin")
|
| 19 |
+
admin.set_password("pass123")
|
| 20 |
+
|
| 21 |
+
student = User(name="Student test", email="student@test.com", role="Student")
|
| 22 |
+
student.set_password("pass123")
|
| 23 |
+
|
| 24 |
+
db.session.add_all([admin, student])
|
| 25 |
+
db.session.commit()
|
| 26 |
+
|
| 27 |
+
yield testing_client
|
| 28 |
+
|
| 29 |
+
db.session.remove()
|
| 30 |
+
db.drop_all()
|
| 31 |
+
|
| 32 |
+
def test_login_page_loads(test_client):
|
| 33 |
+
response = test_client.get('/auth/login')
|
| 34 |
+
assert response.status_code == 200
|
| 35 |
+
|
| 36 |
+
def test_register_page_loads(test_client):
|
| 37 |
+
response = test_client.get('/auth/register')
|
| 38 |
+
assert response.status_code == 200
|
| 39 |
+
|
| 40 |
+
def login(client, email, password):
|
| 41 |
+
return client.post('/auth/login', data=dict(
|
| 42 |
+
email=email,
|
| 43 |
+
password=password
|
| 44 |
+
), follow_redirects=True)
|
| 45 |
+
|
| 46 |
+
def logout(client):
|
| 47 |
+
return client.get('/auth/logout', follow_redirects=True)
|
| 48 |
+
|
| 49 |
+
def test_dashboard_access(test_client):
|
| 50 |
+
# Unauthenticated should redirect
|
| 51 |
+
response = test_client.get('/dashboard/', follow_redirects=False)
|
| 52 |
+
assert response.status_code == 302
|
| 53 |
+
assert '/auth/login' in response.headers.get('Location')
|
| 54 |
+
|
| 55 |
+
# Authenticated should succeed
|
| 56 |
+
login(test_client, 'student@test.com', 'pass123')
|
| 57 |
+
response2 = test_client.get('/dashboard/')
|
| 58 |
+
assert response2.status_code == 200
|
| 59 |
+
|
| 60 |
+
def test_admin_routes_security(test_client):
|
| 61 |
+
# Login as student
|
| 62 |
+
login(test_client, 'student@test.com', 'pass123')
|
| 63 |
+
|
| 64 |
+
# Try access admin route
|
| 65 |
+
response = test_client.get('/admin/create-user', follow_redirects=True)
|
| 66 |
+
# Should be redirected to dashboard
|
| 67 |
+
assert b'Access denied' in response.data
|
| 68 |
+
|
| 69 |
+
logout(test_client)
|
| 70 |
+
|
| 71 |
+
# Login as admin
|
| 72 |
+
login(test_client, 'admin@test.com', 'pass123')
|
| 73 |
+
response2 = test_client.get('/admin/create-user')
|
| 74 |
+
assert response2.status_code == 200
|
| 75 |
+
|
| 76 |
+
response3 = test_client.get('/admin/manage-users')
|
| 77 |
+
assert response3.status_code == 200
|
| 78 |
+
|
| 79 |
+
def test_profile_route(test_client):
|
| 80 |
+
login(test_client, 'student@test.com', 'pass123')
|
| 81 |
+
response = test_client.get('/profile/')
|
| 82 |
+
assert response.status_code == 200
|
| 83 |
+
|
| 84 |
+
def test_submit_feedback_page(test_client):
|
| 85 |
+
login(test_client, 'student@test.com', 'pass123')
|
| 86 |
+
response = test_client.get('/feedback/submit')
|
| 87 |
+
assert response.status_code == 200
|
| 88 |
+
|
| 89 |
+
def test_upload_route_security(test_client):
|
| 90 |
+
# Student cannot upload
|
| 91 |
+
login(test_client, 'student@test.com', 'pass123')
|
| 92 |
+
response = test_client.get('/upload/', follow_redirects=True)
|
| 93 |
+
assert b'permission to access' in b''.join(response.data.split()) or b'permission' in response.data
|
| 94 |
+
logout(test_client)
|
| 95 |
+
|
| 96 |
+
# Admin can upload
|
| 97 |
+
login(test_client, 'admin@test.com', 'pass123')
|
| 98 |
+
response2 = test_client.get('/upload/')
|
| 99 |
+
assert response2.status_code == 200
|
test_model.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from utils.nlp_utils import analyze_sentiment
|
| 2 |
+
|
| 3 |
+
print("Testing Positive:")
|
| 4 |
+
print(analyze_sentiment("This is an absolutely fantastic product, highly recommend!"))
|
| 5 |
+
|
| 6 |
+
print("\nTesting Negative:")
|
| 7 |
+
print(analyze_sentiment("What a terrible waste of money. Completely broken."))
|
| 8 |
+
|
| 9 |
+
print("\nTesting Neutral:")
|
| 10 |
+
print(analyze_sentiment("The package arrived on Tuesday."))
|
utils/decorators.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import wraps
|
| 2 |
+
from flask import redirect, url_for, flash
|
| 3 |
+
from flask_login import current_user
|
| 4 |
+
|
| 5 |
+
def requires_roles(*roles):
|
| 6 |
+
"""
|
| 7 |
+
Decorator to restrict access to endpoints based on user roles.
|
| 8 |
+
"""
|
| 9 |
+
def wrapper(f):
|
| 10 |
+
@wraps(f)
|
| 11 |
+
def wrapped(*args, **kwargs):
|
| 12 |
+
if not current_user.is_authenticated:
|
| 13 |
+
return redirect(url_for('auth.login'))
|
| 14 |
+
if current_user.role not in roles:
|
| 15 |
+
flash("You do not have permission to access this page.", "danger")
|
| 16 |
+
return redirect(url_for('dashboard.index'))
|
| 17 |
+
return f(*args, **kwargs)
|
| 18 |
+
return wrapped
|
| 19 |
+
return wrapper
|
utils/file_processor.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from werkzeug.utils import secure_filename
|
| 4 |
+
from utils.nlp_utils import preprocess_text, analyze_sentiment
|
| 5 |
+
from models import db, Feedback, Upload
|
| 6 |
+
from flask import current_app
|
| 7 |
+
|
| 8 |
+
def allowed_file(filename):
|
| 9 |
+
return '.' in filename and \
|
| 10 |
+
filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS']
|
| 11 |
+
|
| 12 |
+
def identify_text_column(df):
|
| 13 |
+
"""
|
| 14 |
+
Heuristic to find the column most likely containing the feedback text.
|
| 15 |
+
Looks for keywords or selects the column with the longest average string length.
|
| 16 |
+
"""
|
| 17 |
+
# Lowercase column names for easier checking
|
| 18 |
+
col_names = [str(c).lower().strip() for c in df.columns]
|
| 19 |
+
|
| 20 |
+
# Expanded heuristic matching for academic, social media, and product reviews
|
| 21 |
+
keywords = [
|
| 22 |
+
'review_text', 'feedback_text', 'text', 'feedback', 'review',
|
| 23 |
+
'comment', 'description', 'message', 'body', 'content', 'tweet', 'post'
|
| 24 |
+
]
|
| 25 |
+
for kw in keywords:
|
| 26 |
+
for i, col in enumerate(col_names):
|
| 27 |
+
if kw == col or kw in col:
|
| 28 |
+
return df.columns[i]
|
| 29 |
+
|
| 30 |
+
# Fallback: Find column with string type and highest max length
|
| 31 |
+
text_cols = df.select_dtypes(include=['object', 'string']).columns
|
| 32 |
+
if len(text_cols) == 0:
|
| 33 |
+
return None
|
| 34 |
+
|
| 35 |
+
max_len_col = text_cols[0]
|
| 36 |
+
max_len = 0
|
| 37 |
+
|
| 38 |
+
for col in text_cols:
|
| 39 |
+
# Get mean length of strings in this column (sample first 50 rows for speed)
|
| 40 |
+
avg_len = df[col].astype(str).head(50).apply(len).mean()
|
| 41 |
+
if avg_len > max_len:
|
| 42 |
+
max_len = avg_len
|
| 43 |
+
max_len_col = col
|
| 44 |
+
|
| 45 |
+
return max_len_col
|
| 46 |
+
|
| 47 |
+
def process_uploaded_file(filepath, upload_record_id, user_id, selected_column=None):
|
| 48 |
+
"""
|
| 49 |
+
Reads CSV/Excel, processes rows for sentiment, and stores to database.
|
| 50 |
+
"""
|
| 51 |
+
try:
|
| 52 |
+
# Read file
|
| 53 |
+
if filepath.endswith('.csv'):
|
| 54 |
+
df = pd.read_csv(filepath)
|
| 55 |
+
elif filepath.endswith(('.xls', '.xlsx')):
|
| 56 |
+
df = pd.read_excel(filepath)
|
| 57 |
+
else:
|
| 58 |
+
return False, "Unsupported file format"
|
| 59 |
+
|
| 60 |
+
if df.empty:
|
| 61 |
+
return False, "The uploaded file is empty."
|
| 62 |
+
|
| 63 |
+
# Determine target text column
|
| 64 |
+
text_col = selected_column if selected_column and selected_column in df.columns else identify_text_column(df)
|
| 65 |
+
if not text_col:
|
| 66 |
+
return False, "Could not identify a text/feedback column in the dataset."
|
| 67 |
+
|
| 68 |
+
# Look for a department/category column if it exists
|
| 69 |
+
dept_col = None
|
| 70 |
+
dept_keywords = [
|
| 71 |
+
'department', 'category', 'product', 'dept', 'course',
|
| 72 |
+
'branch', 'faculty', 'subject', 'program', 'unit'
|
| 73 |
+
]
|
| 74 |
+
for col in df.columns:
|
| 75 |
+
if any(kw in str(col).lower().strip() for kw in dept_keywords):
|
| 76 |
+
dept_col = col
|
| 77 |
+
break
|
| 78 |
+
|
| 79 |
+
# Process and prepare bulk insert data
|
| 80 |
+
feedbacks = []
|
| 81 |
+
total_rows = len(df)
|
| 82 |
+
|
| 83 |
+
# We can drop NA from the text column to avoid processing empties
|
| 84 |
+
df = df.dropna(subset=[text_col])
|
| 85 |
+
|
| 86 |
+
for index, row in df.iterrows():
|
| 87 |
+
orig_text = str(row[text_col])
|
| 88 |
+
|
| 89 |
+
# Skip very empty rows
|
| 90 |
+
if not orig_text.strip() or orig_text.lower() == 'nan':
|
| 91 |
+
continue
|
| 92 |
+
|
| 93 |
+
cleaned_text = preprocess_text(orig_text)
|
| 94 |
+
sentiment, score = analyze_sentiment(cleaned_text)
|
| 95 |
+
|
| 96 |
+
department = str(row[dept_col]) if dept_col and pd.notna(row[dept_col]) else None
|
| 97 |
+
|
| 98 |
+
feedback = Feedback(
|
| 99 |
+
user_id=user_id,
|
| 100 |
+
upload_id=upload_record_id,
|
| 101 |
+
original_text=orig_text,
|
| 102 |
+
cleaned_text=cleaned_text,
|
| 103 |
+
sentiment=sentiment,
|
| 104 |
+
sentiment_score=score,
|
| 105 |
+
department_category=department
|
| 106 |
+
)
|
| 107 |
+
feedbacks.append(feedback)
|
| 108 |
+
|
| 109 |
+
# Update upload tally
|
| 110 |
+
upload_record = Upload.query.get(upload_record_id)
|
| 111 |
+
if upload_record:
|
| 112 |
+
upload_record.total_rows = total_rows
|
| 113 |
+
upload_record.processed_rows = len(feedbacks)
|
| 114 |
+
upload_record.status = 'Completed'
|
| 115 |
+
|
| 116 |
+
# Bulk save
|
| 117 |
+
db.session.bulk_save_objects(feedbacks)
|
| 118 |
+
db.session.commit()
|
| 119 |
+
|
| 120 |
+
return True, f"Successfully processed {len(feedbacks)} out of {total_rows} rows."
|
| 121 |
+
|
| 122 |
+
except Exception as e:
|
| 123 |
+
db.session.rollback()
|
| 124 |
+
# Mark as failed
|
| 125 |
+
upload_record = Upload.query.get(upload_record_id)
|
| 126 |
+
if upload_record:
|
| 127 |
+
upload_record.status = 'Failed'
|
| 128 |
+
db.session.commit()
|
| 129 |
+
return False, str(e)
|
utils/nlp_utils.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import nltk
|
| 3 |
+
from transformers import pipeline
|
| 4 |
+
import torch
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
# Define custom model path relative to this file
|
| 8 |
+
CUSTOM_MODEL_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "custom_model")
|
| 9 |
+
BASE_MODEL = "cardiffnlp/twitter-roberta-base-sentiment-latest"
|
| 10 |
+
|
| 11 |
+
try:
|
| 12 |
+
# We use device=0 if GPU is available, else -1 for CPU
|
| 13 |
+
device = 0 if torch.cuda.is_available() else -1
|
| 14 |
+
|
| 15 |
+
# Check if we have a locally fine-tuned model
|
| 16 |
+
if os.path.exists(CUSTOM_MODEL_DIR):
|
| 17 |
+
print("Detected local completely CUSTOM fine-tuned AI model! Loading...")
|
| 18 |
+
sentiment_pipeline = pipeline(
|
| 19 |
+
"sentiment-analysis",
|
| 20 |
+
model=CUSTOM_MODEL_DIR,
|
| 21 |
+
tokenizer=CUSTOM_MODEL_DIR,
|
| 22 |
+
device=device
|
| 23 |
+
)
|
| 24 |
+
else:
|
| 25 |
+
print("Loading default Hugging Face Transformers model.")
|
| 26 |
+
sentiment_pipeline = pipeline(
|
| 27 |
+
"sentiment-analysis",
|
| 28 |
+
model=BASE_MODEL,
|
| 29 |
+
device=device
|
| 30 |
+
)
|
| 31 |
+
print("AI Engine Online.")
|
| 32 |
+
except Exception as e:
|
| 33 |
+
print(f"Failed to load Transformers model: {e}")
|
| 34 |
+
sentiment_pipeline = None
|
| 35 |
+
|
| 36 |
+
# Basic stop words fallback if NLTK fails to load
|
| 37 |
+
FALLBACK_STOPWORDS = {"i", "me", "my", "myself", "we", "our", "ours", "ourselves", "you", "your", "yours", "yourself", "yourselves", "he", "him", "his", "himself", "she", "her", "hers", "herself", "it", "its", "itself", "they", "them", "their", "theirs", "themselves", "what", "which", "who", "whom", "this", "that", "these", "those", "am", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had", "having", "do", "does", "did", "doing", "a", "an", "the", "and", "but", "if", "or", "because", "as", "until", "while", "of", "at", "by", "for", "with", "about", "against", "between", "into", "through", "during", "before", "after", "above", "below", "to", "from", "up", "down", "in", "out", "on", "off", "over", "under", "again", "further", "then", "once", "here", "there", "when", "where", "why", "how", "all", "any", "both", "each", "few", "more", "most", "other", "some", "such", "no", "nor", "not", "only", "own", "same", "so", "than", "too", "very", "s", "t", "can", "will", "just", "don", "should", "now"}
|
| 38 |
+
|
| 39 |
+
# Attempt to load NLTK corpora gracefully
|
| 40 |
+
stop_words = FALLBACK_STOPWORDS
|
| 41 |
+
use_nltk_tokenize = False
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
try:
|
| 45 |
+
nltk.data.find('corpora/stopwords')
|
| 46 |
+
except LookupError:
|
| 47 |
+
nltk.download('stopwords', quiet=True)
|
| 48 |
+
from nltk.corpus import stopwords
|
| 49 |
+
stop_words = set(stopwords.words('english'))
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
nltk.data.find('tokenizers/punkt')
|
| 53 |
+
except LookupError:
|
| 54 |
+
nltk.download('punkt', quiet=True)
|
| 55 |
+
nltk.download('punkt_tab', quiet=True)
|
| 56 |
+
use_nltk_tokenize = True
|
| 57 |
+
except Exception as e:
|
| 58 |
+
# Environment blocked NLTK Zip extraction (e.g. Zip Slip blocked)
|
| 59 |
+
print(f"NLTK initialization encountered an error: {e}. Falling back to basic tokenization.")
|
| 60 |
+
|
| 61 |
+
def preprocess_text(text):
|
| 62 |
+
"""
|
| 63 |
+
Cleans text by removing punctuation, converting to lowercase,
|
| 64 |
+
and removing stopwords.
|
| 65 |
+
"""
|
| 66 |
+
if not isinstance(text, str):
|
| 67 |
+
return ""
|
| 68 |
+
|
| 69 |
+
# Lowercase
|
| 70 |
+
text = text.lower()
|
| 71 |
+
|
| 72 |
+
# Remove punctuation & special characters
|
| 73 |
+
text = re.sub(r'[^\w\s]', '', text)
|
| 74 |
+
|
| 75 |
+
# Tokenization and Stopword removal
|
| 76 |
+
if use_nltk_tokenize:
|
| 77 |
+
try:
|
| 78 |
+
from nltk.tokenize import word_tokenize
|
| 79 |
+
tokens = word_tokenize(text)
|
| 80 |
+
except Exception:
|
| 81 |
+
tokens = text.split()
|
| 82 |
+
else:
|
| 83 |
+
tokens = text.split()
|
| 84 |
+
|
| 85 |
+
filtered_tokens = [w for w in tokens if w not in stop_words]
|
| 86 |
+
|
| 87 |
+
return ' '.join(filtered_tokens)
|
| 88 |
+
|
| 89 |
+
def analyze_sentiment(text):
|
| 90 |
+
"""
|
| 91 |
+
Calculates polarity and categorizes sentiment using huggingface pipeline.
|
| 92 |
+
Returns (sentiment_category, polarity_score)
|
| 93 |
+
"""
|
| 94 |
+
if not text or not sentiment_pipeline:
|
| 95 |
+
# Fallback to neutral if pipeline fails or text is empty
|
| 96 |
+
return 'Neutral', 0.0
|
| 97 |
+
|
| 98 |
+
try:
|
| 99 |
+
# The pipeline may fail on texts that are too long (e.g., > 512 tokens)
|
| 100 |
+
# We truncate the input string to roughly 2000 characters as a safe fallback
|
| 101 |
+
safe_text = text[:2000]
|
| 102 |
+
|
| 103 |
+
result = sentiment_pipeline(safe_text)[0]
|
| 104 |
+
label = result['label'].lower()
|
| 105 |
+
score = result['score'] # Confidence score between 0 and 1
|
| 106 |
+
|
| 107 |
+
# Convert label to our standard format and map score to [-1, 1] range to match DB expectations
|
| 108 |
+
# Support both direct string evaluation and underlying LABEL_id mappings (0: Neg, 1: Neu, 2: Pos for CardiffNLP)
|
| 109 |
+
if label == 'positive' or label == 'label_2':
|
| 110 |
+
return 'Positive', float(score)
|
| 111 |
+
elif label == 'negative' or label == 'label_0':
|
| 112 |
+
return 'Negative', -float(score)
|
| 113 |
+
else:
|
| 114 |
+
return 'Neutral', 0.0
|
| 115 |
+
|
| 116 |
+
except Exception as e:
|
| 117 |
+
print(f"Error in sentiment pipeline: {e}")
|
| 118 |
+
return 'Neutral', 0.0
|
| 119 |
+
|