add license

This commit is contained in:
Alejandro Vazquez
2026-03-26 10:14:40 -03:00
commit 48bace5167
29 changed files with 2802 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
FLASK_APP=app.py
FLASK_ENV=development
SECRET_KEY=your-secret-key-here-please-change-in-production
DATABASE_URL=sqlite:///edu_space.db
API_BASE_URL=http://localhost:8080/api
+249
View File
@@ -0,0 +1,249 @@
# Edu-Space Admin Panel
A comprehensive web-based administration interface for the Edu-Space Classroom Reservation System built with Flask.
## Features
### 🔐 Authentication & Security
- Secure login system with hashed passwords
- Role-based access control
- Session management with Flask-Login
- Password change functionality
### 🏫 Classroom Management
- Create, edit, and delete classrooms
- Campus and building organization
- Capacity and resource tracking
- Real-time availability checking
### 📅 Schedule Management
- Interactive calendar view (FullCalendar)
- Reservation creation and management
- Conflict detection and prevention
- Status tracking (Pending, Confirmed, Cancelled)
- Today's schedule overview
### 📊 Dashboard & Analytics
- Real-time statistics
- Monthly reservation charts
- Quick actions interface
- Upcoming reservations overview
### 🎨 Modern UI/UX
- Responsive Bootstrap 5 design
- Dark theme support
- Mobile-friendly interface
- Interactive components and charts
## Tech Stack
- **Backend**: Flask (Python)
- **Database**: SQLAlchemy (SQLite/PostgreSQL)
- **Frontend**: Bootstrap 5, jQuery, Chart.js
- **Authentication**: Flask-Login, Werkzeug
- **Forms**: Flask-WTF
- **Calendar**: FullCalendar.js
## Installation
### Prerequisites
- Python 3.8+
- pip package manager
### Setup
1. **Clone or setup the project:**
```bash
cd /Users/alejandro.v/PycharmProjects/admin-edu-space
```
2. **Create virtual environment:**
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. **Install dependencies:**
```bash
pip install -r requirements.txt
```
4. **Setup environment variables:**
```bash
cp .env.example .env
# Edit .env with your configuration
```
5. **Initialize database:**
```bash
python app.py
```
## Configuration
### Environment Variables (.env)
```env
FLASK_APP=app.py
FLASK_ENV=development
SECRET_KEY=your-secret-key-here-please-change-in-production
DATABASE_URL=sqlite:///edu_space.db
API_BASE_URL=http://localhost:8080/api
```
## Usage
### Start the Application
```bash
python app.py
```
The application will be available at: `http://localhost:5000`
### Default Login
- **Email**: admin@edu-space.com
- **Password**: admin123
### Main Features
1. **Dashboard**: Overview of system statistics and upcoming reservations
2. **Classrooms**: Manage classroom information, capacity, and resources
3. **Schedule**: View and manage reservations with calendar interface
4. **Profile**: User profile and password management
## API Integration
The admin panel integrates with the Spring Boot API at `http://localhost:8080/api`. Make sure the Java microservice is running for full functionality.
## Project Structure
```
admin-edu-space/
├── app/
│ ├── __init__.py # Flask app factory
│ ├── models/ # Database models
│ │ ├── user.py
│ │ ├── classroom.py
│ │ ├── subject.py
│ │ └── reservation.py
│ ├── routes/ # Application routes
│ │ ├── auth.py
│ │ ├── main.py
│ │ ├── classrooms.py
│ │ └── schedule.py
│ ├── forms/ # WTForms classes
│ │ ├── auth.py
│ │ ├── classroom.py
│ │ └── reservation.py
│ ├── templates/ # Jinja2 templates
│ ├── static/ # Static files
├── config/ # Configuration files
├── requirements.txt
├── .env
├── app.py
└── README.md
```
## Database Schema
### Users Table
- User authentication and authorization
- Role management (admin, staff)
### Classrooms Table
- Classroom information
- Capacity and location details
- Resource management
### Reservations Table
- Booking management
- Time slot scheduling
- Status tracking
### Subjects + Commissions Tables
- Academic information
- Teacher assignments
- Student enrollment tracking
## Security Features
- Password hashing with bcrypt
- Session management
- CSRF protection
- Input validation and sanitization
- SQL injection prevention with SQLAlchemy ORM
## Development
### Running in Development Mode
```bash
export FLASK_ENV=development
python app.py
```
### Adding New Features
1. Create models in `app/models/`
2. Add routes in `app/routes/`
3. Create forms if needed in `app/forms/`
4. Update templates in `app/templates/`
## Production Deployment
### Environment Setup
1. **Set production variables:**
```env
FLASK_ENV=production
SECRET_KEY=<strong-random-string>
DATABASE_URL=<production-database-url>
```
2. **Use production WSGI server:**
```bash
pip install gunicorn
gunicorn -w 4 -b 0.0.0.0:5000 app:app
```
3. **Configure reverse proxy** (nginx/Apache)
## Troubleshooting
### Common Issues
1. **Database connection errors:**
- Check DATABASE_URL string
- Ensure directory permissions
2. **Login issues:**
- Verify admin user exists
- Check password hash
3. **Static files not loading:**
- Ensure blueprint route registration
- Check file permissions
## Contributing
1. Fork the repository
2. Create feature branch
3. Make changes
4. Test thoroughly
5. Submit pull request
## License
This project is part of the Edu-Space educational management system.
## Support
For support and questions:
- Email: admin@edu-space.com
- Documentation: Check inline comments and README files
---
**Note**: This admin panel is designed to work alongside the Spring Boot microservice. Ensure both services are running for complete functionality.
+6
View File
@@ -0,0 +1,6 @@
from app import create_app
app = create_app()
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5000)
+65
View File
@@ -0,0 +1,65 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_migrate import Migrate
from flask_cors import CORS
from dotenv import load_dotenv
from config.config import Config
import os
load_dotenv()
db = SQLAlchemy()
login_manager = LoginManager()
migrate = Migrate()
def create_app():
app = Flask(__name__)
# Configuration
app.config.from_object(Config)
# Initialize extensions
db.init_app(app)
login_manager.init_app(app)
migrate.init_app(app, db)
CORS(app)
# Login manager configuration
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
@login_manager.user_loader
def load_user(user_id):
from app.models.user import User
return User.query.get(int(user_id))
# Register blueprints
from app.routes.auth import auth_bp
from app.routes.main import main_bp
from app.routes.classrooms import classrooms_bp
from app.routes.schedule import schedule_bp
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
app.register_blueprint(classrooms_bp)
app.register_blueprint(schedule_bp)
# Create database tables
with app.app_context():
db.create_all()
# Create default admin user if not exists
from app.models.user import User
if not User.query.filter_by(email='admin@edu-space.com').first():
admin = User(
email='admin@edu-space.com',
name='System Administrator',
role='admin'
)
admin.set_password('admin123') # Change this in production
db.session.add(admin)
db.session.commit()
return app
View File
+13
View File
@@ -0,0 +1,13 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, Length
class LoginForm(FlaskForm):
email = StringField('Email', validators=[
DataRequired(),
Email(),
Length(max=120)
])
password = PasswordField('Password', validators=[DataRequired()])
remember_me = BooleanField('Remember me')
submit = SubmitField('Sign In')
+58
View File
@@ -0,0 +1,58 @@
from flask_wtf import FlaskForm
from wtforms import StringField, IntegerField, SelectField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, NumberRange, Length, Optional
class ClassroomForm(FlaskForm):
code = StringField('Classroom Code', validators=[
DataRequired(),
Length(min=2, max=20, message='Code must be between 2 and 20 characters')
])
name = StringField('Classroom Name', validators=[
DataRequired(),
Length(max=100)
])
building = StringField('Building', validators=[
DataRequired(),
Length(max=50)
])
floor = IntegerField('Floor', validators=[
DataRequired(),
NumberRange(min=1, max=10)
])
capacity = IntegerField('Capacity', validators=[
DataRequired(),
NumberRange(min=1, max=200)
])
description = TextAreaField('Description', validators=[Optional(), Length(max=500)])
campus_location = SelectField('Campus Location',
choices=[
('Main Campus', 'Main Campus'),
('North Campus', 'North Campus'),
('Science & Technology Campus', 'Science & Technology Campus'),
('Downtown Campus', 'Downtown Campus')
],
validators=[DataRequired()])
submit = SubmitField('Save Classroom')
class ClassroomResourceForm(FlaskForm):
resource_type = SelectField('Resource Type',
choices=[
('PROJECTOR', 'Projector'),
('WHITEBOARD', 'Whiteboard'),
('SMART_BOARD', 'Smart Board'),
('SCREEN', 'Screen'),
('AUDIO_SYSTEM', 'Audio System'),
('COMPUTERS', 'Computers'),
('LAB_EQUIPMENT', 'Lab Equipment'),
('AIR_CONDITIONING', 'Air Conditioning'),
('HEATING', 'Heating')
],
validators=[DataRequired()])
brand = StringField('Brand', validators=[Optional(), Length(max=100)])
model = StringField('Model', validators=[Optional(), Length(max=100)])
specifications = TextAreaField('Specifications', validators=[Optional(), Length(max=1000)])
quantity = IntegerField('Quantity', validators=[
DataRequired(),
NumberRange(min=1, max=50)
], default=1)
submit = SubmitField('Add Resource')
+19
View File
@@ -0,0 +1,19 @@
from flask_wtf import FlaskForm
from wtforms import StringField, SelectField, IntegerField, TextAreaField, SubmitField, DateTimeLocalField
from wtforms.validators import DataRequired, NumberRange, Length, Optional
class ReservationForm(FlaskForm):
classroom_id = SelectField('Classroom', coerce=int, validators=[DataRequired()])
commission_id = SelectField('Commission', coerce=int, validators=[DataRequired()])
start_time = DateTimeLocalField('Start Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
end_time = DateTimeLocalField('End Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
purpose = StringField('Purpose', validators=[
DataRequired(),
Length(max=500, message='Purpose must be less than 500 characters')
])
expected_attendees = IntegerField('Expected Attendees', validators=[
DataRequired(),
NumberRange(min=1, max=200, message='Number of attendees must be between 1 and 200')
])
notes = TextAreaField('Notes', validators=[Optional(), Length(max=1000)])
submit = SubmitField('Save Reservation')
+6
View File
@@ -0,0 +1,6 @@
from .user import User
from .classroom import Classroom
from .subject import Subject
from .reservation import Reservation
__all__ = ['User', 'Classroom', 'Subject', 'Reservation']
+79
View File
@@ -0,0 +1,79 @@
from app import db
from datetime import datetime
class Classroom(db.Model):
__tablename__ = 'classrooms'
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(20), unique=True, nullable=False, index=True)
name = db.Column(db.String(100), nullable=False)
building = db.Column(db.String(50), nullable=False)
floor = db.Column(db.Integer, nullable=False)
capacity = db.Column(db.Integer, nullable=False)
description = db.Column(db.Text)
campus_location = db.Column(db.String(50), default='Main Campus')
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
reservations = db.relationship('Reservation', backref='classroom', lazy=True, cascade='all, delete-orphan')
# Resources relationship (教室资源)
resources = db.relationship('ClassroomResource', backref='classroom', lazy=True, cascade='all, delete-orphan')
def to_dict(self):
"""Convert classroom to dictionary"""
return {
'id': self.id,
'code': self.code,
'name': self.name,
'building': self.building,
'floor': self.floor,
'capacity': self.capacity,
'description': self.description,
'campus_location': self.campus_location,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'resources': [resource.to_dict() for resource in self.resources]
}
def get_full_location(self):
"""Get full classroom location"""
return f"{self.building} - Floor {self.floor}"
def __repr__(self):
return f'<Classroom {self.code}>'
class ClassroomResource(db.Model):
__tablename__ = 'classroom_resources'
id = db.Column(db.Integer, primary_key=True)
classroom_id = db.Column(db.Integer, db.ForeignKey('classrooms.id'), nullable=False)
resource_type = db.Column(db.String(50), nullable=False) # PROJECTOR, WHITEBOARD, COMPUTER, etc.
brand = db.Column(db.String(100))
model = db.Column(db.String(100))
specifications = db.Column(db.Text)
quantity = db.Column(db.Integer, default=1)
is_functional = db.Column(db.Boolean, default=True)
last_maintenance = db.Column(db.DateTime)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
"""Convert resource to dictionary"""
return {
'id': self.id,
'classroom_id': self.classroom_id,
'resource_type': self.resource_type,
'brand': self.brand,
'model': self.model,
'specifications': self.specifications,
'quantity': self.quantity,
'is_functional': self.is_functional,
'last_maintenance': self.last_maintenance.isoformat() if self.last_maintenance else None,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<Resource {self.resource_type} for Classroom {self.classroom_id}>'
+88
View File
@@ -0,0 +1,88 @@
from app import db
from datetime import datetime, timedelta
from enum import Enum
class ReservationStatus(Enum):
PENDING = 'PENDING'
CONFIRMED = 'CONFIRMED'
CANCELLED = 'CANCELLED'
COMPLETED = 'COMPLETED'
NO_SHOW = 'NO_SHOW'
class Reservation(db.Model):
__tablename__ = 'reservations'
id = db.Column(db.Integer, primary_key=True)
classroom_id = db.Column(db.Integer, db.ForeignKey('classrooms.id'), nullable=False)
commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
start_time = db.Column(db.DateTime, nullable=False)
end_time = db.Column(db.DateTime, nullable=False)
purpose = db.Column(db.String(500), nullable=False)
status = db.Column(db.Enum(ReservationStatus), default=ReservationStatus.PENDING, nullable=False)
expected_attendees = db.Column(db.Integer, nullable=False)
notes = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
"""Convert reservation to dictionary"""
return {
'id': self.id,
'classroom_id': self.classroom_id,
'commission_id': self.commission_id,
'user_id': self.user_id,
'start_time': self.start_time.isoformat() if self.start_time else None,
'end_time': self.end_time.isoformat() if self.end_time else None,
'purpose': self.purpose,
'status': self.status.value if self.status else None,
'expected_attendees': self.expected_attendees,
'notes': self.notes,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'classroom': self.classroom.to_dict() if self.classroom else None,
'commission': self.commission.to_dict() if self.commission else None,
'user': self.user.to_dict() if self.user else None
}
def is_confirmed(self):
"""Check if reservation is confirmed"""
return self.status == ReservationStatus.CONFIRMED
def can_be_cancelled(self):
"""Check if reservation can be cancelled (24 hours before start)"""
from datetime import timedelta
return self.start_time > datetime.utcnow() + timedelta(hours=24)
def check_conflict(self, other_reservation):
"""Check if this reservation conflicts with another"""
if self.classroom_id != other_reservation.classroom_id:
return False
if self.status != ReservationStatus.CONFIRMED:
return False
if other_reservation.status != ReservationStatus.CONFIRMED:
return False
# Check time overlap
return (self.start_time < other_reservation.end_time and
self.end_time > other_reservation.start_time)
@staticmethod
def find_conflicts(classroom_id, start_time, end_time, exclude_reservation_id=None):
"""Find conflicting reservations for a specific classroom and time slot"""
query = Reservation.query.filter(
Reservation.classroom_id == classroom_id,
Reservation.status == ReservationStatus.CONFIRMED,
Reservation.start_time < end_time,
Reservation.end_time > start_time
)
if exclude_reservation_id:
query = query.filter(Reservation.id != exclude_reservation_id)
return query.all()
def __repr__(self):
return f'<Reservation {self.id}>'
+84
View File
@@ -0,0 +1,84 @@
from app import db
from datetime import datetime
class Subject(db.Model):
__tablename__ = 'subjects'
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(20), unique=True, nullable=False, index=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
department = db.Column(db.String(100), nullable=False)
credits = db.Column(db.Integer)
duration_minutes = db.Column(db.Integer, default=90)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
commissions = db.relationship('Commission', backref='subject', lazy=True, cascade='all, delete-orphan')
def to_dict(self):
"""Convert subject to dictionary"""
return {
'id': self.id,
'code': self.code,
'name': self.name,
'description': self.description,
'department': self.department,
'credits': self.credits,
'duration_minutes': self.duration_minutes,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f'<Subject {self.code}>'
class Commission(db.Model):
__tablename__ = 'commissions'
id = db.Column(db.Integer, primary_key=True)
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False)
code = db.Column(db.String(50), nullable=False)
semester = db.Column(db.String(20), nullable=False) # Fall, Spring, Summer
year = db.Column(db.Integer, nullable=False)
teacher_name = db.Column(db.String(100), nullable=False)
max_students = db.Column(db.Integer, default=50)
current_students = db.Column(db.Integer, default=0)
schedule = db.Column(db.String(255)) # e.g., "MWF 9:00-10:30 AM"
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
reservations = db.relationship('Reservation', backref='commission', lazy=True, cascade='all, delete-orphan')
# Unique constraint
__table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),)
def to_dict(self):
"""Convert commission to dictionary"""
return {
'id': self.id,
'subject_id': self.subject_id,
'code': self.code,
'semester': self.semester,
'year': self.year,
'teacher_name': self.teacher_name,
'max_students': self.max_students,
'current_students': self.current_students,
'schedule': self.schedule,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'subject': self.subject.to_dict() if self.subject else None
}
def get_full_code(self):
"""Get full commission code"""
return f"{self.subject.code if self.subject else 'N/A'}-{self.code}-{self.semester.upper()}{self.year if self.year else ''}"
def __repr__(self):
return f'<Commission {self.get_full_code()}>'
+51
View File
@@ -0,0 +1,51 @@
from app import db, login_manager
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
name = db.Column(db.String(100), nullable=False)
role = db.Column(db.String(20), default='admin', nullable=False) # admin, staff
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime)
# Relationships
reservations = db.relationship('Reservation', backref='user', lazy=True, cascade='all, delete-orphan')
def set_password(self, password):
"""Set password hash"""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""Check password"""
return check_password_hash(self.password_hash, password)
def is_admin(self):
"""Check if user is admin"""
return self.role == 'admin'
def update_last_login(self):
"""Update last login timestamp"""
self.last_login = datetime.utcnow()
db.session.commit()
def to_dict(self):
"""Convert user to dictionary"""
return {
'id': self.id,
'email': self.email,
'name': self.name,
'role': self.role,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_login': self.last_login.isoformat() if self.last_login else None
}
def __repr__(self):
return f'<User {self.email}>'
View File
+80
View File
@@ -0,0 +1,80 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, session
from flask_login import login_user, logout_user, login_required, current_user
from app import db
from app.models.user import User
from app.forms.auth import LoginForm
from datetime import datetime
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""Handle user login"""
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user and user.check_password(form.password.data):
if not user.is_active:
flash('Your account has been deactivated. Please contact administrator.', 'error')
return render_template('auth/login.html', form=form)
login_user(user, remember=form.remember_me.data)
user.update_last_login()
next_page = request.args.get('next')
if not next_page or not next_page.startswith('/'):
next_page = url_for('main.dashboard')
flash(f'Welcome back, {user.name}!', 'success')
return redirect(next_page)
else:
flash('Invalid email or password.', 'error')
return render_template('auth/login.html', form=form)
@auth_bp.route('/logout')
@login_required
def logout():
"""Handle user logout"""
logout_user()
flash('You have been logged out successfully.', 'info')
return redirect(url_for('auth.login'))
@auth_bp.route('/profile')
@login_required
def profile():
"""User profile page"""
return render_template('auth/profile.html')
@auth_bp.route('/change_password', methods=['GET', 'POST'])
@login_required
def change_password():
"""Change user password"""
if request.method == 'POST':
current_password = request.form.get('current_password')
new_password = request.form.get('new_password')
confirm_password = request.form.get('confirm_password')
if not current_user.check_password(current_password):
flash('Current password is incorrect.', 'error')
return redirect(url_for('auth.change_password'))
if new_password != confirm_password:
flash('New passwords do not match.', 'error')
return redirect(url_for('auth.change_password'))
if len(new_password) < 6:
flash('Password must be at least 6 characters long.', 'error')
return redirect(url_for('auth.change_password'))
current_user.set_password(new_password)
db.session.commit()
flash('Password changed successfully!', 'success')
return redirect(url_for('auth.profile'))
return render_template('auth/change_password.html')
+214
View File
@@ -0,0 +1,214 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required
from app import db
from app.models.classroom import Classroom, ClassroomResource
from app.models.reservation import Reservation, ReservationStatus
from app.forms.classroom import ClassroomForm, ClassroomResourceForm
from datetime import datetime
classrooms_bp = Blueprint('classrooms', __name__, url_prefix='/classrooms')
@classrooms_bp.route('/')
@login_required
def list_classrooms():
"""List all classrooms"""
page = request.args.get('page', 1, type=int)
search = request.args.get('search', '')
campus = request.args.get('campus', '')
query = Classroom.query.filter_by(is_active=True)
if search:
query = query.filter(
Classroom.code.contains(search) |
Classroom.name.contains(search) |
Classroom.building.contains(search)
)
if campus:
query = query.filter(Classroom.campus_location == campus)
classrooms = query.paginate(
page=page,
per_page=20,
error_out=False
)
return render_template('classrooms/list.html',
classrooms=classrooms,
search=search,
campus=campus)
@classrooms_bp.route('/add', methods=['GET', 'POST'])
@login_required
def add_classroom():
"""Add new classroom"""
form = ClassroomForm()
if form.validate_on_submit():
classroom = Classroom(
code=form.code.data,
name=form.name.data,
building=form.building.data,
floor=form.floor.data,
capacity=form.capacity.data,
description=form.description.data,
campus_location=form.campus_location.data
)
try:
db.session.add(classroom)
db.session.commit()
flash(f'Classroom {classroom.code} added successfully!', 'success')
return redirect(url_for('classrooms.list_classrooms'))
except Exception as e:
db.session.rollback()
flash('Error adding classroom. Classroom code might already exist.', 'error')
return render_template('classrooms/add.html', form=form)
@classrooms_bp.route('/edit/<int:Id>', methods=['GET', 'POST'])
@login_required
def edit_classroom(Id):
"""Edit classroom"""
classroom = Classroom.query.get_or_404(Id)
if not classroom.is_active:
flash('This classroom has been deactivated.', 'warning')
return redirect(url_for('classrooms.list_classrooms'))
form = ClassroomForm(obj=classroom)
if form.validate_on_submit():
try:
classroom.code = form.code.data
classroom.name = form.name.data
classroom.building = form.building.data
classroom.floor = form.floor.data
classroom.capacity = form.capacity.data
classroom.description = form.description.data
classroom.campus_location = form.campus_location.data
classroom.updated_at = datetime.utcnow()
db.session.commit()
flash(f'Classroom {classroom.code} updated successfully!', 'success')
return redirect(url_for('classrooms.view_classroom', Id=classroom.id))
except Exception as e:
db.session.rollback()
flash('Error updating classroom.', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom)
@classrooms_bp.route('/view/<int:Id>')
@login_required
def view_classroom(Id):
"""View classroom details"""
classroom = Classroom.query.get_or_404(Id)
if not classroom.is_active:
flash('This classroom has been deactivated.', 'warning')
return redirect(url_for('classrooms.list_classrooms'))
# Get classroom resources
resources = ClassroomResource.query.filter_by(classroom_id=Id).all()
# Get upcoming reservations
upcoming_reservations = Reservation.query.filter(
Reservation.classroom_id == Id,
Reservation.start_time >= datetime.utcnow(),
Reservation.status.in_([ReservationStatus.CONFIRMED, ReservationStatus.PENDING])
).order_by(Reservation.start_time).limit(10).all()
return render_template('classrooms/view.html',
classroom=classroom,
resources=resources,
upcoming_reservations=upcoming_reservations)
@classrooms_bp.route('/delete/<int:Id>')
@login_required
def delete_classroom(Id):
"""Delete/deactivate classroom"""
classroom = Classroom.query.get_or_404(Id)
# Check for active reservations
active_reservations = Reservation.query.filter(
Reservation.classroom_id == Id,
Reservation.status.in_([ReservationStatus.CONFIRMED, ReservationStatus.PENDING])
).count()
if active_reservations > 0:
flash('Cannot delete classroom with active reservations.', 'error')
return redirect(url_for('classrooms.view_classroom', Id=Id))
try:
classroom.is_active = False
classroom.updated_at = datetime.utcnow()
db.session.commit()
flash(f'Classroom {classroom.code} has been deactivated.', 'success')
except Exception as e:
db.session.rollback()
flash('Error deactivating classroom.', 'error')
return redirect(url_for('classrooms.list_classrooms'))
@classrooms_bp.route('/<int:classroom_id>/add_resource', methods=['GET', 'POST'])
@login_required
def add_resource(classroom_id):
"""Add resource to classroom"""
classroom = Classroom.query.get_or_404(classroom_id)
form = ClassroomResourceForm()
if form.validate_on_submit():
resource = ClassroomResource(
classroom_id=classroom_id,
resource_type=form.resource_type.data,
brand=form.brand.data,
model=form.model.data,
specifications=form.specifications.data,
quantity=form.quantity.data
)
try:
db.session.add(resource)
db.session.commit()
flash(f'{resource.resource_type} added to {classroom.code} successfully!', 'success')
return redirect(url_for('classrooms.view_classroom', Id=classroom_id))
except Exception as e:
db.session.rollback()
flash('Error adding resource.', 'error')
return render_template('classrooms/add_resource.html', form=form, classroom=classroom)
@classrooms_bp.route('/api/available')
@login_required
def api_available_classrooms():
"""API endpoint to get available classrooms"""
start_time = request.args.get('start_time')
end_time = request.args.get('end_time')
min_capacity = request.args.get('min_capacity', 0, type=int)
if not start_time or not end_time:
return jsonify({'error': 'start_time and end_time are required'}), 400
try:
start_dt = datetime.fromisoformat(start_time)
end_dt = datetime.fromisoformat(end_time)
except ValueError:
return jsonify({'error': 'Invalid datetime format'}), 400
# Find available classrooms (without conflicts)
conflicting_classroom_ids = [
r.classroom_id for r in Reservation.query.filter(
Reservation.start_time < end_dt,
Reservation.end_time > start_dt,
Reservation.status == ReservationStatus.CONFIRMED
).all()
]
available_classrooms = Classroom.query.filter(
Classroom.is_active == True,
Classroom.capacity >= min_capacity,
~Classroom.id.in_(conflicting_classroom_ids)
).all()
return jsonify([classroom.to_dict() for classroom in available_classrooms])
+92
View File
@@ -0,0 +1,92 @@
from flask import Blueprint, render_template, jsonify
from flask_login import login_required, current_user
from app.models.reservation import Reservation
from app.models.classroom import Classroom
from app.models.subject import Subject
from datetime import datetime, timedelta
from sqlalchemy import func
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
@login_required
def dashboard():
"""Main dashboard page"""
# Get statistics
total_classrooms = Classroom.query.filter_by(is_active=True).count()
total_subjects = Subject.query.filter_by(is_active=True).count()
# Get today's reservations
today = datetime.utcnow().date()
today_reservations = Reservation.query.filter(
func.date(Reservation.start_time) == today
).count()
# Get this week's reservations
week_start = today - timedelta(days=today.weekday())
week_end = week_start + timedelta(days=6)
week_reservations = Reservation.query.filter(
func.date(Reservation.start_time) >= week_start,
func.date(Reservation.start_time) <= week_end
).count()
# Get upcoming reservations (next 7 days)
upcoming_reservations = Reservation.query.filter(
Reservation.start_time >= datetime.utcnow(),
Reservation.start_time <= datetime.utcnow() + timedelta(days=7),
Reservation.status.in_(['CONFIRMED', 'PENDING'])
).join(Classroom).join(Subject).limit(10).all()
stats = {
'total_classrooms': total_classrooms,
'total_subjects': total_subjects,
'today_reservations': today_reservations,
'week_reservations': week_reservations
}
return render_template('dashboard.html',
stats=stats,
upcoming_reservations=upcoming_reservations)
@main_bp.route('/api/dashboard-stats')
@login_required
def dashboard_stats_api():
"""API endpoint for dashboard statistics"""
# Get today's data
today = datetime.utcnow().date()
today_reservations = Reservation.query.filter(
func.date(Reservation.start_time) == today
).count()
# Get this week's data
week_start = today - timedelta(days=today.weekday())
week_end = week_start + timedelta(days=6)
week_reservations = Reservation.query.filter(
func.date(Reservation.start_time) >= week_start,
func.date(Reservation.start_time) <= week_end
).count()
# Get monthly data for chart
current_date = datetime.utcnow()
monthly_data = []
for i in range(12):
month_date = current_date - timedelta(days=30 * i)
month_start = month_date.replace(day=1)
month_end = (month_start + timedelta(days=32)).replace(day=1) - timedelta(days=1)
monthly_reservations = Reservation.query.filter(
func.date(Reservation.start_time) >= month_start.date(),
func.date(Reservation.start_time) <= month_end.date()
).count()
monthly_data.append({
'month': month_start.strftime('%Y-%m'),
'reservations': monthly_reservations
})
return jsonify({
'today_reservations': today_reservations,
'week_reservations': week_reservations,
'monthly_data': list(reversed(monthly_data))
})
+277
View File
@@ -0,0 +1,277 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from app import db
from app.models.reservation import Reservation, ReservationStatus
from app.models.classroom import Classroom
from app.models.subject import Subject, Commission
from app.forms.reservation import ReservationForm
from datetime import datetime, timedelta
from sqlalchemy import func, or_
schedule_bp = Blueprint('schedule', __name__, url_prefix='/schedule')
@schedule_bp.route('/')
@login_required
def calendar_view():
"""Calendar view of reservations"""
return render_template('schedule/calendar.html')
@schedule_bp.route('/list')
@login_required
def list_reservations():
"""List all reservations"""
page = request.args.get('page', 1, type=int)
status_filter = request.args.get('status', '')
classroom_filter = request.args.get('classroom', 0, type=int)
date_filter = request.args.get('date', '')
query = Reservation.query
# Apply filters
if status_filter:
query = query.filter(Reservation.status == status_filter)
if classroom_filter:
query = query.filter(Reservation.classroom_id == classroom_filter)
if date_filter:
try:
filter_date = datetime.fromisoformat(date_filter).date()
query = query.filter(func.date(Reservation.start_time) == filter_date)
except ValueError:
pass
reservations = query.order_by(Reservation.start_time.desc()).paginate(
page=page,
per_page=20,
error_out=False
)
# Get filter options
classrooms = Classroom.query.filter_by(is_active=True).all()
return render_template('schedule/list.html',
reservations=reservations,
classrooms=classrooms,
status_filter=status_filter,
classroom_filter=classroom_filter,
date_filter=date_filter)
@schedule_bp.route('/add', methods=['GET', 'POST'])
@login_required
def add_reservation():
"""Add new reservation"""
form = ReservationForm()
# Populate classroom choices
classrooms = Classroom.query.filter_by(is_active=True).all()
form.classroom_id.choices = [(c.id, c'{c.code} - {c.name} ({c.capacity} seats)') for c in classrooms]
# Populate commission choices
commissions = Commission.query.filter_by(is_active=True).all()
form.commission_id.choices = [(c.id, c'{c.get_full_code()} - {c.teacher_name}') for c in commissions]
if form.validate_on_submit():
# Check for conflicts
conflicts = Reservation.find_conflicts(
form.classroom_id.data,
form.start_time.data,
form.end_time.data
)
if conflicts:
flash('Classroom is already booked for this time slot.', 'error')
return render_template('schedule/add.html', form=form)
# Check if end time is after start time
if form.end_time.data <= form.start_time.data:
flash('End time must be after start time.', 'error')
return render_template('schedule/add.html', form=form)
# Check capacity
classroom = Classroom.query.get(form.classroom_id.data)
if form.expected_attendees.data > classroom.capacity:
flash('Number of attendees exceeds classroom capacity.', 'error')
return render_template('schedule/add.html', form=form)
reservation = Reservation(
classroom_id=form.classroom_id.data,
commission_id=form.commission_id.data,
user_id=current_user.id,
start_time=form.start_time.data,
end_time=form.end_time.data,
purpose=form.purpose.data,
expected_attendees=form.expected_attendees.data,
notes=form.notes.data,
status=ReservationStatus.PENDING
)
try:
db.session.add(reservation)
db.session.commit()
flash('Reservation created successfully!', 'success')
return redirect(url_for('schedule.view_reservation', Id=reservation.id))
except Exception as e:
db.session.rollback()
flash('Error creating reservation.', 'error')
return render_template('schedule/add.html', form=form)
@schedule_bp.route('/view/<int:Id>')
@login_required
def view_reservation(Id):
"""View reservation details"""
reservation = Reservation.query.get_or_404(Id)
return render_template('schedule/view.html', reservation=reservation)
@schedule_bp.route('/edit/<int:Id>', methods=['GET', 'POST'])
@login_required
def edit_reservation(Id):
"""Edit reservation"""
reservation = Reservation.query.get_or_404(Id)
# Only allow editing pending reservations
if reservation.status != ReservationStatus.PENDING:
flash('Only pending reservations can be edited.', 'warning')
return redirect(url_for('schedule.view_reservation', Id=Id))
form = ReservationForm(obj=reservation)
# Populate choices
classrooms = Classroom.query.filter_by(is_active=True).all()
form.classroom_id.choices = [(c.id, c'{c.code} - {c.name} ({c.capacity} seats)') for c in classrooms]
commissions = Commission.query.filter_by(is_active=True).all()
form.commission_id.choices = [(c.id, c'{c.get_full_code()} - {c.teacher_name}') for c in commissions]
if form.validate_on_submit():
# Check for conflicts (excluding this reservation)
conflicts = Reservation.find_conflicts(
form.classroom_id.data,
form.start_time.data,
form.end_time.data,
exclude_reservation_id=Id
)
if conflicts:
flash('Classroom is already booked for this time slot.', 'error')
return render_template('schedule/edit.html', form=form, reservation=reservation)
# Update reservation
reservation.classroom_id = form.classroom_id.data
reservation.commission_id = form.commission_id.data
reservation.start_time = form.start_time.data
reservation.end_time = form.end_time.data
reservation.purpose = form.purpose.data
reservation.expected_attendees = form.expected_attendees.data
reservation.notes = form.notes.data
reservation.updated_at = datetime.utcnow()
try:
db.session.commit()
flash('Reservation updated successfully!', 'success')
return redirect(url_for('schedule.view_reservation', Id=Id))
except Exception as e:
db.session.rollback()
flash('Error updating reservation.', 'error')
return render_template('schedule/edit.html', form=form, reservation=reservation)
@schedule_bp.route('/confirm/<int:Id>')
@login_required
def confirm_reservation(Id):
"""Confirm a reservation"""
reservation = Reservation.query.get_or_404(Id)
if reservation.status != ReservationStatus.PENDING:
flash('Only pending reservations can be confirmed.', 'warning')
return redirect(url_for('schedule.view_reservation', Id=Id))
try:
reservation.status = ReservationStatus.CONFIRMED
reservation.updated_at = datetime.utcnow()
db.session.commit()
flash('Reservation confirmed successfully!', 'success')
except Exception as e:
db.session.rollback()
flash('Error confirming reservation.', 'error')
return redirect(url_for('schedule.view_reservation', Id=Id))
@schedule_bp.route('/cancel/<int:Id>')
@login_required
def cancel_reservation(Id):
"""Cancel a reservation"""
reservation = Reservation.query.get_or_404(Id)
if reservation.status not in [ReservationStatus.PENDING, ReservationStatus.CONFIRMED]:
flash('Cannot cancel this reservation.', 'warning')
return redirect(url_for('schedule.view_reservation', Id=Id))
try:
reservation.status = ReservationStatus.CANCELLED
reservation.updated_at = datetime.utcnow()
db.session.commit()
flash('Reservation cancelled successfully!', 'success')
except Exception as e:
db.session.rollback()
flash('Error cancelling reservation.', 'error')
return redirect(url_for('schedule.view_reservation', Id=Id))
@schedule_bp.route('/calendar-data')
@login_required
def calendar_data():
"""API endpoint for calendar data"""
start = request.args.get('start')
end = request.args.get('end')
if not start or not end:
return jsonify([])
try:
start_date = datetime.fromisoformat(start)
end_date = datetime.fromisoformat(end)
except ValueError:
return jsonify([])
reservations = Reservation.query.filter(
Reservation.start_time >= start_date,
Reservation.start_time <= end_date,
Reservation.status.in_([ReservationStatus.CONFIRMED, ReservationStatus.PENDING])
).all()
events = []
for reservation in reservations:
color = {
ReservationStatus.CONFIRMED: '#28a745',
ReservationStatus.PENDING: '#ffc107'
}.get(reservation.status, '#6c757d')
events.append({
'id': reservation.id,
'title': f'{reservation.classroom.code} - {reservation.commission.get_full_code()}',
'start': reservation.start_time.isoformat(),
'end': reservation.end_time.isoformat(),
'url': url_for('schedule.view_reservation', Id=reservation.id),
'backgroundColor': color,
'borderColor': color
})
return jsonify(events)
@schedule_bp.route('/today')
@login_required
def today_schedule():
"""Show today's schedule"""
today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
reservations = Reservation.query.filter(
Reservation.start_time >= today_start,
Reservation.start_time <= today_end,
Reservation.status.in_([ReservationStatus.CONFIRMED, ReservationStatus.PENDING])
).order_by(Reservation.start_time).all()
return render_template('schedule/today.html', reservations=reservations)
+220
View File
@@ -0,0 +1,220 @@
/* Custom CSS for Edu-Space Admin */
/* Dark Theme Enhancements */
body {
background-color: #1a1a1a;
}
/* Card Styles */
.card {
border-radius: 15px;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
/* Navigation */
.navbar {
backdrop-filter: blur(10px);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.navbar-brand {
font-weight: 600;
}
/* Button Styles */
.btn {
border-radius: 8px;
font-weight: 500;
transition: all 0.2s ease-in-out;
}
.btn:hover {
transform: translateY(-1px);
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
.btn-primary:hover {
background: linear-gradient(135deg, #5568d8 0%, #654198 100%);
}
/* Form Styles */
.form-control, .form-select {
border-radius: 8px;
border: 1px solid #444;
background-color: #2a2a2a;
color: #fff;
transition: border-color 0.2s ease-in-out;
}
.form-control:focus, .form-select:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
background-color: #2a2a2a;
color: #fff;
}
.table {
background-color: transparent;
}
.table {
background-color: transparent;
}
.table:hover tbody tr:hover {
background-color: rgba(102, 126, 234, 0.05);
}
/* Badge Styles */
.badge {
font-size: 0.75em;
padding: 0.5em 0.8em;
}
/* Status Badge Colors */
.bg-confirmed-custom {
background-color: #28a745;
}
.bg-pending-custom {
background-color: #ffc107;
color: #000;
}
.bg-cancelled-custom {
background-color: #dc3545;
}
/* Loading Spinner */
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #667eea;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Calendar Customizations */
.fc-event {
border-radius: 6px;
border: none !important;
padding: 4px 8px !important;
font-size: 12px !important;
margin: 1px 0 !important;
}
.fc-day-sat, .fc-day-sun {
background-color: rgba(255, 255, 255, 0.02) !important;
}
/* Dashboard Stat Cards */
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 15px;
}
.stat-card-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
.stat-card-success { background: linear-gradient(135deg, #28a745 0%, #20c997 100%); }
.stat-card-info { background: linear-gradient(135deg, #17a2b8 0%, #138496 100%); }
.stat-card-warning { background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%); }
/* Quick Action Buttons */
.quick-action-btn {
text-align: left;
padding: 12px 16px;
margin-bottom: 8px;
border-radius: 10px;
transition: all 0.2s ease-in-out;
}
.quick-action-btn:hover {
transform: translateX(5px);
}
/* Empty State Styles */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #6c757d;
}
.empty-state i {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
/* Notification Toastenhancements */
.toast {
backdrop-filter: blur(10px);
border-radius: 10px;
}
/* Pagination Styles */
.pagination .page-link {
border-radius: 8px;
margin: 0 2px;
border: 1px solid #444;
background-color: #2a2a2a;
color: #fff;
}
.pagination .page-link:hover {
background-color: #667eea;
border-color: #667eea;
color: white;
}
.pagination .page-item.active .page-link {
background-color: #667eea;
border-color: #667eea;
}
/* Mobile Responsiveness */
@media (max-width: 768px) {
.card {
margin-bottom: 1rem;
}
.btn-group {
flex-direction: column;
}
.btn-group .btn {
margin-bottom: 0.5rem;
}
}
/* Print Styles */
@media print {
.navbar, .btn, .pagination, .dropdown {
display: none !important;
}
.card {
border: 1px solid #ddd !important;
box-shadow: none !important;
break-inside: avoid;
}
body {
background-color: white !important;
color: black !important;
}
}
+303
View File
@@ -0,0 +1,303 @@
// Main JavaScript file for Edu-Space Admin
$(document).ready(function() {
// Initialize tooltips
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
// Initialize popovers
var popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
var popoverList = popoverTriggerList.map(function (popoverTriggerEl) {
return new bootstrap.Popover(popoverTriggerEl);
});
// Auto-hide alerts after 5 seconds
setTimeout(function() {
$('.alert.fade').alert('close');
}, 5000);
// Confirm delete actions
$('.delete-confirm').on('click', function(e) {
if (!confirm('Are you sure you want to delete this item? This action cannot be undone.')) {
e.preventDefault();
return false;
}
});
// Form validation enhancements
$('form').on('submit', function() {
var form = $(this);
var submitBtn = form.find('button[type="submit"]');
// Show loading state
submitBtn.prop('disabled', true);
var originalText = submitBtn.html();
submitBtn.html('<i class="bi bi-hourglass-split me-2"></i>Loading...');
// Re-enable after 10 seconds in case of error
setTimeout(function() {
submitBtn.prop('disabled', false);
submitBtn.html(originalText);
}, 10000);
});
// Copy to clipboard functionality
$('.copy-to-clipboard').on('click', function() {
var text = $(this).data('text');
if (!text) {
text = $(this).text();
}
navigator.clipboard.writeText(text).then(function() {
showToast('Copied to clipboard!', 'success');
}).catch(function() {
showToast('Failed to copy to clipboard', 'error');
});
});
// Quick search functionality
$('#quickSearch').on('input', function() {
var searchTerm = $(this).val().toLowerCase();
$('.searchable-item').each(function() {
var item = $(this);
var text = item.text().toLowerCase();
if (text.includes(searchTerm)) {
item.show();
} else {
item.hide();
}
});
});
// Initialize datepickers
$('.datetimepicker').each(function() {
$(this).flatpickr({
enableTime: true,
dateFormat: "Y-m-d H:i",
time_24hr: true,
minDate: "today"
});
});
$('.datepicker').each(function() {
$(this).flatpickr({
dateFormat: "Y-m-d",
minDate: "today"
});
});
// Auto-save functionality for multi-step forms
$('.auto-save').on('change input', function() {
var form = $(this).closest('form');
var formData = form.serialize();
// Save to localStorage
localStorage.setItem('autosave_' + form.attr('id'), formData);
showToast('Draft saved automatically', 'info');
});
// Restore auto-saved data
$('form.auto-save-restore').each(function() {
var form = $(this);
var savedData = localStorage.getItem('autosave_' + form.attr('id'));
if (savedData) {
// Show restore prompt
if (confirm('Found unsaved changes. Would you like to restore them?')) {
var params = new URLSearchParams(savedData);
params.forEach(function(value, key) {
form.find('[name="' + key + '"]').val(value);
});
showToast('Draft restored', 'info');
}
}
});
// Print functionality
$('.btn-print').on('click', function() {
window.print();
});
// Export functionality
$('.btn-export').on('click', function() {
var format = $(this).data('format');
var url = $(this).data('url');
if (format === 'excel') {
window.open(url + '?export=excel', '_blank');
} else if (format === 'pdf') {
window.open(url + '?export=pdf', '_blank');
} else if (format === 'csv') {
window.open(url + '?export=csv', '_blank');
}
});
// Keyboard shortcuts
$(document).on('keydown', function(e) {
// Ctrl/Cmd + K for search
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
$('#quickSearch').focus();
}
// Escape to close modals
if (e.key === 'Escape') {
$('.modal.show').modal('hide');
}
});
// Initialize tooltips dynamically for AI-generated content
initializeDynamicTooltips();
});
// Utility functions
function showToast(message, type = 'info') {
var toastHtml = `
<div class="toast align-items-center text-white bg-${type === 'error' ? 'danger' : type} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`;
var toastContainer = $('.toast-container');
var toastElement = $(toastHtml);
toastContainer.append(toastElement);
var toast = new bootstrap.Toast(toastElement[0]);
toast.show();
// Remove toast element after it's hidden
toastElement.on('hidden.bs.toast', function() {
$(this).remove();
});
}
function confirmAction(message, callback) {
if (confirm(message)) {
callback();
}
}
function formatDateTime(dateTimeString) {
var date = new Date(dateTimeString);
return date.toLocaleString();
}
function formatDate(dateString) {
var date = new Date(dateString);
return date.toLocaleDateString();
}
function formatTime(timeString) {
var date = new Date(timeString);
return date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
function initializeDynamicTooltips() {
// Initialize tooltips for dynamically generated content
$('[data-bs-toggle="tooltip"]:not([aria-describedby])').each(function() {
new bootstrap.Tooltip(this);
});
}
// API Helper functions
function apiCall(url, method = 'GET', data = null) {
return $.ajax({
url: url,
method: method,
data: data,
headers: {
'X-CSRFToken': csrfToken || ''
}
});
}
function handleApiError(xhr) {
if (xhr.responseJSON && xhr.responseJSON.message) {
showToast(xhr.responseJSON.message, 'error');
} else {
showToast('An error occurred. Please try again.', 'error');
}
}
// Form validation helpers
function validateForm(form) {
var isValid = true;
var errors = [];
form.find('[required]').each(function() {
var field = $(this);
if (!field.val()) {
isValid = false;
errors.push(field.attr('data-label') || field.attr('name'));
field.addClass('is-invalid');
} else {
field.removeClass('is-invalid');
}
});
if (!isValid) {
showToast('Please fill in the required fields: ' + errors.join(', '), 'error');
}
return isValid;
}
// Image preview functionality
function previewImage(input, previewElement) {
if (input.files && input.files[0]) {
var reader = new FileReader();
reader.onload = function(e) {
previewElement.attr('src', e.target.result);
};
reader.readAsDataURL(input.files[0]);
}
}
// Export to CSV function
function exportToCSV(data, filename) {
var csv = '';
var headers = Object.keys(data[0]);
// Add headers
csv += headers.join(',') + '\n';
// Add data
data.forEach(function(row) {
headers.forEach(function(header, index) {
if (index > 0) csv += ',';
csv += '"' + (row[header] || '') + '"';
});
csv += '\n';
});
// Create download link
var hiddenElement = document.createElement('a');
hiddenElement.href = 'data:text/csv;charset=utf-8,' + encodeURI(csv);
hiddenElement.target = '_blank';
hiddenElement.download = filename + '.csv';
hiddenElement.click();
}
+100
View File
@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Edu-Space Admin</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
max-width: 450px;
width: 100%;
}
.logo-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20px 20px 0 0;
padding: 2rem;
text-align: center;
color: white;
}
</style>
</head>
<body>
<div class="login-card">
<div class="logo-section">
<i class="bi bi-building" style="font-size: 3rem;"></i>
<h3 class="mt-3">Edu-Space Admin</h3>
<p class="mb-0">Classroom Management System</p>
</div>
<div class="p-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
<label for="email" class="form-label">{{ form.email.label.text }}</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-envelope"></i></span>
{{ form.email(class="form-control", placeholder="Enter your email") }}
</div>
{% for error in form.email.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
<label for="password" class="form-label">{{ form.password.label.text }}</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-lock"></i></span>
{{ form.password(class="form-control", placeholder="Enter your password") }}
</div>
{% for error in form.password.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3 form-check">
{{ form.remember_me(class="form-check-input") }}
{{ form.remember_me.label(class="form-check-label") }}
</div>
<div class="d-grid">
{{ form.submit(class="btn btn-primary btn-lg") }}
</div>
</form>
<div class="text-center mt-3">
<small class="text-muted">
<i class="bi bi-shield-lock"></i>
Secure Login • All access is logged
</small>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+138
View File
@@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Edu-Space Admin{% endblock %}</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<!-- Custom CSS -->
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
{% block extra_css %}{% endblock %}
</head>
<body>
{% if current_user.is_authenticated %}
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-building"></i> Edu-Space Admin
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-door-open"></i> Classrooms
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}">
<i class="bi bi-list"></i> All Classrooms
</a></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}">
<i class="bi bi-plus-circle"></i> Add Classroom
</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-calendar3"></i> Schedule
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}">
<i class="bi bi-calendar-week"></i> Calendar View
</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}">
<i class="bi bi-list-check"></i> All Reservations
</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}">
<i class="bi bi-plus-circle"></i> New Reservation
</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}">
<i class="bi bi-calendar-today"></i> Today's Schedule
</a></li>
</ul>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ current_user.name }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
<i class="bi bi-person"></i> Profile
</a></li>
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
<i class="bi bi-key"></i> Change Password
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right"></i> Logout
</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="container-fluid pt-5 mt-4">
{% block content %}{% endblock %}
</main>
{% else %}
<main class="container-fluid">
{% block content %}{% endblock %}
</main>
{% endif %}
<!-- Flash Messages -->
<div class="toast-container position-fixed bottom-0 end-0 p-3">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="toast align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' if category == 'info' else 'warning' }} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
<!-- Show flash messages -->
<script>
$(document).ready(function() {
$('.toast').toast('show');
});
</script>
{% block extra_js %}{% endblock %}
</body>
</html>
+184
View File
@@ -0,0 +1,184 @@
{% extends "base.html" %}
{% block title %}Classrooms - Edu-Space Admin{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-12">
<h1 class="h3 mb-0">
<i class="bi bi-door-open text-primary"></i> Classrooms
<small class="text-muted ms-2">Management</small>
</h1>
</div>
</div>
<!-- Search and Filter -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<form method="GET" class="row g-3">
<div class="col-md-4">
<input type="text" class="form-control" name="search"
placeholder="Search classrooms..." value="{{ search }}">
</div>
<div class="col-md-3">
<select class="form-select" name="campus">
<option value="">All Campuses</option>
<option value="Main Campus" {% if campus == 'Main Campus' %}selected{% endif %}>Main Campus</option>
<option value="North Campus" {% if campus == 'North Campus' %}selected{% endif %}>North Campus</option>
<option value="Science & Technology Campus" {% if campus == 'Science & Technology Campus' %}selected{% endif %}>Science & Technology Campus</option>
<option value="Downtown Campus" {% if campus == 'Downtown Campus' %}selected{% endif %}>Downtown Campus</option>
</select>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i> Search
</button>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-clockwise"></i> Reset
</a>
</div>
<div class="col-md-2 text-end">
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-success">
<i class="bi bi-plus-circle"></i> Add Classroom
</a>
</div>
</form>
</div>
</div>
<!-- Classrooms Grid -->
<div class="row">
{% for classroom in classrooms.items %}
<div class="col-md-6 col-lg-4 mb-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="card-title mb-1">{{ classroom.code }}</h5>
<p class="text-muted small mb-0">{{ classroom.name }}</p>
</div>
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('classrooms.view_classroom', Id=classroom.id) }}">
<i class="bi bi-eye"></i> View Details
</a></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.edit_classroom', Id=classroom.id) }}">
<i class="bi bi-pencil"></i> Edit
</a></li>
<li><a class="dropdown-item text-danger" href="{{ url_for('classrooms.delete_classroom', Id=classroom.id) }}">
<i class="bi bi-trash"></i> Delete
</a></li>
</ul>
</div>
</div>
</div>
<div class="card-body">
<div class="row g-2">
<div class="col-6">
<div class="d-flex align-items-center">
<i class="bi bi-building text-primary me-2"></i>
<span class="small">{{ classroom.building }}</span>
</div>
</div>
<div class="col-6">
<div class="d-flex align-items-center">
<i class="bi bi-layers text-info me-2"></i>
<span class="small">Floor {{ classroom.floor }}</span>
</div>
</div>
<div class="col-6">
<div class="d-flex align-items-center">
<i class="bi bi-people text-success me-2"></i>
<span class="small">{{ classroom.capacity }} seats</span>
</div>
</div>
<div class="col-6">
<div class="d-flex align-items-center">
<i class="bi bi-geo-alt text-warning me-2"></i>
<span class="small">{{ classroom.campus_location }}</span>
</div>
</div>
</div>
{% if classroom.description %}
<div class="mt-3">
<p class="small text-muted">{{ classroom.description[:100] }}{% if classroom.description|length > 100 %}...{% endif %}</p>
</div>
{% endif %}
<div class="mt-3">
<span class="badge bg-success">
<i class="bi bi-check-circle"></i> Active
</span>
</div>
</div>
<div class="card-footer bg-white border-0">
<div class="d-grid">
<a href="{{ url_for('classrooms.view_classroom', Id=classroom.id) }}"
class="btn btn-outline-primary btn-sm">
<i class="bi bi-eye"></i> View Details
</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if classrooms.pages > 1 %}
<nav aria-label="Classrooms pagination">
<ul class="pagination justify-content-center">
{% if classrooms.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.prev_num, search=search, campus=campus) }}">
<i class="bi bi-chevron-left"></i>
</a>
</li>
{% endif %}
{% for page_num in classrooms.iter_pages() %}
{% if page_num %}
{% if page_num != classrooms.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=page_num, search=search, campus=campus) }}">
{{ page_num }}
</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endfor %}
{% if classrooms.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.next_num, search=search, campus=campus) }}">
<i class="bi bi-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% if not classrooms.items %}
<div class="text-center py-5">
<i class="bi bi-x-circle text-muted" style="font-size: 3rem;"></i>
<h4 class="text-muted mt-3">No classrooms found</h4>
<p class="text-muted">Try adjusting your search criteria or add a new classroom.</p>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add First Classroom
</a>
</div>
{% endif %}
{% endblock %}
+231
View File
@@ -0,0 +1,231 @@
{% extends "base.html" %}
{% block title %}Dashboard - Edu-Space Admin{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-12">
<h1 class="h3 mb-0">
<i class="bi bi-speedometer2 text-primary"></i> Dashboard
<small class="text-muted ms-2">Overview</small>
</h1>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">Total Classrooms</h6>
<h2 class="mb-0">{{ stats.total_classrooms }}</h2>
</div>
<div class="bg-primary bg-opacity-10 rounded-circle p-3">
<i class="bi bi-door-open text-primary fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">Total Subjects</h6>
<h2 class="mb-0">{{ stats.total_subjects }}</h2>
</div>
<div class="bg-success bg-opacity-10 rounded-circle p-3">
<i class="bi bi-book text-success fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">Today's Reservations</h6>
<h2 class="mb-0">{{ stats.today_reservations }}</h2>
</div>
<div class="bg-info bg-opacity-10 rounded-circle p-3">
<i class="bi bi-calendar-today text-info fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">This Week</h6>
<h2 class="mb-0">{{ stats.week_reservations }}</h2>
</div>
<div class="bg-warning bg-opacity-10 rounded-circle p-3">
<i class="bi bi-calendar-week text-warning fs-4"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Monthly Chart -->
<div class="col-md-8 mb-4">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<h5 class="mb-0">
<i class="bi bi-graph-up text-primary"></i> Monthly Reservations
</h5>
</div>
<div class="card-body">
<canvas id="monthlyChart" height="100"></canvas>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="col-md-4 mb-4">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<h5 class="mb-0">
<i class="bi bi-lightning text-warning"></i> Quick Actions
</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-outline-primary">
<i class="bi bi-plus-circle"></i> Add Classroom
</a>
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-outline-success">
<i class="bi bi-calendar-plus"></i> New Reservation
</a>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
<i class="bi bi-calendar-today"></i> Today's Schedule
</a>
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-secondary">
<i class="bi bi-calendar3"></i> View Calendar
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Upcoming Reservations -->
<div class="row">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-clock-history text-info"></i> Upcoming Reservations (Next 7 Days)
</h5>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-sm btn-outline-primary">
View All
</a>
</div>
</div>
<div class="card-body">
{% if upcoming_reservations %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Date & Time</th>
<th>Classroom</th>
<th>Subject</th>
<th>Teacher</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for reservation in upcoming_reservations %}
<tr>
<td>{{ reservation.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ reservation.classroom.code }}</td>
<td>{{ reservation.commission.get_full_code() }}</td>
<td>{{ reservation.commission.teacher_name }}</td>
<td>
<span class="badge bg-{{ 'success' if reservation.status.value == 'CONFIRMED' else 'warning' }}">
{{ reservation.status.value }}
</span>
</td>
<td>
<a href="{{ url_for('schedule.view_reservation', Id=reservation.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="bi bi-calendar-x text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-3">No upcoming reservations in the next 7 days</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
$(document).ready(function() {
// Fetch monthly data and create chart
$.get('/api/dashboard-stats')
.done(function(data) {
const ctx = document.getElementById('monthlyChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: data.monthly_data.map(item => item.month),
datasets: [{
label: 'Reservations',
data: data.monthly_data.map(item => item.reservations),
borderColor: '#667eea',
backgroundColor: 'rgba(102, 126, 234, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
}
});
});
});
</script>
{% endblock %}
+173
View File
@@ -0,0 +1,173 @@
{% extends "base.html" %}
{% block title %}Schedule Calendar - Edu-Space Admin{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-12">
<h1 class="h3 mb-0">
<i class="bi bi-calendar3 text-primary"></i> Schedule Calendar
<small class="text-muted ms-2">View & Manage Reservations</small>
</h1>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<div class="row align-items-center">
<div class="col-md-6">
<h5 class="mb-0">
<i class="bi bi-calendar-week text-info"></i> Reservations Calendar
</h5>
</div>
<div class="col-md-6 text-end">
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Reservation
</a>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-list"></i> List View
</a>
</div>
</div>
</div>
<div class="card-body">
<!-- Legend -->
<div class="mb-3">
<div class="d-flex gap-3 flex-wrap">
<div class="d-flex align-items-center">
<span class="badge bg-success me-2" style="width: 20px; height: 20px;"></span>
<small class="text-muted">Confirmed</small>
</div>
<div class="d-flex align-items-center">
<span class="badge bg-warning me-2" style="width: 20px; height: 20px;"></span>
<small class="text-muted">Pending</small>
</div>
</div>
</div>
<!-- Calendar -->
<div id="calendar"></div>
</div>
</div>
<!-- Reservation Details Modal -->
<div class="modal fade" id="reservationModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Reservation Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="reservationDetails">
<!-- Content will be loaded dynamically -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.css" rel="stylesheet">
<style>
#calendar {
margin-top: 20px;
}
.fc-event {
cursor: pointer;
border: none !important;
padding: 2px 6px !important;
font-size: 12px !important;
}
.fc-event-pending {
background-color: #ffc107 !important;
}
.fc-event-confirmed {
background-color: #28a745 !important;
}
.fc-daygrid-day.fc-day-today {
background-color: rgba(102, 126, 234, 0.1) !important;
}
</style>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
var calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'timeGridWeek',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
},
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: false
},
slotMinTime: '07:00:00',
slotMaxTime: '22:00:00',
height: 'auto',
eventClick: function(info) {
// Prevent default link behavior
if (info.event.url) {
// Load reservation details in modal instead of navigation
loadReservationDetails(info.event.url);
return false;
}
},
events: '{{ url_for("schedule.calendar_data") }}',
loading: function(bool) {
// Show loading indicator if needed
}
});
calendar.render();
function loadReservationDetails(url) {
$.get(url)
.done(function(data) {
var html = `
<div class="row">
<div class="col-md-6">
<h6 class="text-muted mb-2">Classroom</h6>
<p class="fw-bold">${data.classroom.code} - ${data.classroom.name}</p>
<p><i class="bi bi-building text-primary"></i> ${data.classroom.building} - Floor ${data.classroom.floor}</p>
</div>
<div class="col-md-6">
<h6 class="text-muted mb-2">Subject</h6>
<p class="fw-bold">${data.commission.subject.code} - ${data.commission.subject.name}</p>
<p><i class="bi bi-person text-info"></i> ${data.commission.teacher_name}</p>
</div>
</div>
<div class="row mt-3">
<div class="col-md-6">
<h6 class="text-muted mb-2">Schedule</h6>
<p><i class="bi bi-clock text-warning"></i> ${new Date(data.start_time).toLocaleString()}</p>
<p><i class="bi bi-clock text-success"></i> ${new Date(data.end_time).toLocaleString()}</p>
</div>
<div class="col-md-6">
<h6 class="text-muted mb-2">Details</h6>
<p><i class="bi bi-people text-primary"></i> ${data.expected_attendees} attendees</p>
<p><span class="badge bg-${data.status === 'CONFIRMED' ? 'success' : 'warning'}">${data.status}</span></p>
</div>
</div>
${data.notes ? `<div class="row mt-3"><div class="col-12"><h6 class="text-muted mb-2">Notes</h6><p>${data.notes}</p></div></div>` : ''}
`;
$('#reservationDetails').html(html);
$('#reservationModal').modal('show');
})
.fail(function() {
$('#reservationDetails').html('<p class="text-danger">Error loading reservation details.</p>');
$('#reservationModal').modal('show');
});
}
});
</script>
{% endblock %}
View File
+15
View File
@@ -0,0 +1,15 @@
import os
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///edu_space.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
API_BASE_URL = os.environ.get('API_BASE_URL') or 'http://localhost:8080/api'
# Pagination
ITEMS_PER_PAGE = 20
# Upload settings
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size
UPLOAD_FOLDER = 'app/static/uploads'
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
+14
View File
@@ -0,0 +1,14 @@
Flask==3.0.0
Flask-SQLAlchemy==3.1.1
Flask-Login==0.6.3
Flask-WTF==1.2.1
Flask-Migrate==4.0.5
Flask-CORS==4.0.0
WTForms==3.1.0
Werkzeug==3.0.1
SQLAlchemy==2.0.23
python-dotenv==1.0.0
requests==2.31.0
bleach==6.1.0
python-dateutil==2.8.2
Pillow==10.1.0
+38
View File
@@ -0,0 +1,38 @@
from setuptools import setup, find_packages
setup(
name="edu-space-admin",
version="1.0.0",
description="Administrative interface for Edu-Space Classroom Management System",
author="Edu-Space Development Team",
author_email="admin@edu-space.com",
packages=find_packages(),
include_package_data=True,
install_requires=[
"Flask==3.0.0",
"Flask-SQLAlchemy==3.1.1",
"Flask-Login==0.6.3",
"Flask-WTF==1.2.1",
"Flask-Migrate==4.0.5",
"Flask-CORS==4.0.0",
"WTForms==3.1.0",
"Werkzeug==3.0.1",
"SQLAlchemy==2.0.23",
"python-dotenv==1.0.0",
"requests==2.31.0",
"bleach==6.1.0",
"python-dateutil==2.8.2",
"Pillow==10.1.0"
],
python_requires=">=3.8",
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Education",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
],
)