commit 48bace5167581a8e3f06389915424ad538d47dfc Author: Alejandro Vazquez Date: Thu Mar 26 10:14:40 2026 -0300 add license diff --git a/.env b/.env new file mode 100644 index 0000000..a9da184 --- /dev/null +++ b/.env @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8aec599 --- /dev/null +++ b/README.md @@ -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= +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. \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..3536bfc --- /dev/null +++ b/app.py @@ -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) \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..6c301f9 --- /dev/null +++ b/app/__init__.py @@ -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 \ No newline at end of file diff --git a/app/forms/__init__.py b/app/forms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/forms/auth.py b/app/forms/auth.py new file mode 100644 index 0000000..8122e48 --- /dev/null +++ b/app/forms/auth.py @@ -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') \ No newline at end of file diff --git a/app/forms/classroom.py b/app/forms/classroom.py new file mode 100644 index 0000000..f43def3 --- /dev/null +++ b/app/forms/classroom.py @@ -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') \ No newline at end of file diff --git a/app/forms/reservation.py b/app/forms/reservation.py new file mode 100644 index 0000000..52e9053 --- /dev/null +++ b/app/forms/reservation.py @@ -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') \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..7bbf8bf --- /dev/null +++ b/app/models/__init__.py @@ -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'] \ No newline at end of file diff --git a/app/models/classroom.py b/app/models/classroom.py new file mode 100644 index 0000000..110c20e --- /dev/null +++ b/app/models/classroom.py @@ -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'' + +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'' \ No newline at end of file diff --git a/app/models/reservation.py b/app/models/reservation.py new file mode 100644 index 0000000..df16730 --- /dev/null +++ b/app/models/reservation.py @@ -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'' \ No newline at end of file diff --git a/app/models/subject.py b/app/models/subject.py new file mode 100644 index 0000000..7e6d91b --- /dev/null +++ b/app/models/subject.py @@ -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'' + +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'' \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..b6aede1 --- /dev/null +++ b/app/models/user.py @@ -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'' \ No newline at end of file diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..caf5beb --- /dev/null +++ b/app/routes/auth.py @@ -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') \ No newline at end of file diff --git a/app/routes/classrooms.py b/app/routes/classrooms.py new file mode 100644 index 0000000..b0b2aba --- /dev/null +++ b/app/routes/classrooms.py @@ -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/', 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/') +@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/') +@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('//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]) \ No newline at end of file diff --git a/app/routes/main.py b/app/routes/main.py new file mode 100644 index 0000000..200341d --- /dev/null +++ b/app/routes/main.py @@ -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)) + }) \ No newline at end of file diff --git a/app/routes/schedule.py b/app/routes/schedule.py new file mode 100644 index 0000000..81d6554 --- /dev/null +++ b/app/routes/schedule.py @@ -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/') +@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/', 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/') +@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/') +@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) \ No newline at end of file diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..6c2b1ec --- /dev/null +++ b/app/static/css/style.css @@ -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; + } +} \ No newline at end of file diff --git a/app/static/js/main.js b/app/static/js/main.js new file mode 100644 index 0000000..c872c00 --- /dev/null +++ b/app/static/js/main.js @@ -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('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 = ` + + `; + + 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(); +} \ No newline at end of file diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..6b8d7ee --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,100 @@ + + + + + + Login - Edu-Space Admin + + + + + + + + + + \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..fec41e4 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,138 @@ + + + + + + {% block title %}Edu-Space Admin{% endblock %} + + + + + + + + + {% block extra_css %}{% endblock %} + + + {% if current_user.is_authenticated %} + + + + +
+ {% block content %}{% endblock %} +
+ {% else %} +
+ {% block content %}{% endblock %} +
+ {% endif %} + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} +
+ + + + + + + + + + + + {% block extra_js %}{% endblock %} + + \ No newline at end of file diff --git a/app/templates/classrooms/list.html b/app/templates/classrooms/list.html new file mode 100644 index 0000000..a71c432 --- /dev/null +++ b/app/templates/classrooms/list.html @@ -0,0 +1,184 @@ +{% extends "base.html" %} + +{% block title %}Classrooms - Edu-Space Admin{% endblock %} + +{% block content %} +
+
+

+ Classrooms + Management +

+
+
+ + +
+
+
+
+ +
+
+ +
+
+ + + Reset + +
+ +
+
+
+ + +
+ {% for classroom in classrooms.items %} +
+
+
+
+
+
{{ classroom.code }}
+

{{ classroom.name }}

+
+ +
+
+
+
+
+
+ + {{ classroom.building }} +
+
+
+
+ + Floor {{ classroom.floor }} +
+
+
+
+ + {{ classroom.capacity }} seats +
+
+
+
+ + {{ classroom.campus_location }} +
+
+
+ + {% if classroom.description %} +
+

{{ classroom.description[:100] }}{% if classroom.description|length > 100 %}...{% endif %}

+
+ {% endif %} + +
+ + Active + +
+
+ +
+
+ {% endfor %} +
+ + +{% if classrooms.pages > 1 %} + +{% endif %} + +{% if not classrooms.items %} +
+ +

No classrooms found

+

Try adjusting your search criteria or add a new classroom.

+ + Add First Classroom + +
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..54b58ce --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,231 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - Edu-Space Admin{% endblock %} + +{% block content %} +
+
+

+ Dashboard + Overview +

+
+
+ + +
+
+
+
+
+
+
Total Classrooms
+

{{ stats.total_classrooms }}

+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Total Subjects
+

{{ stats.total_subjects }}

+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Today's Reservations
+

{{ stats.today_reservations }}

+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
This Week
+

{{ stats.week_reservations }}

+
+
+ +
+
+
+
+
+
+ +
+ +
+
+
+
+ Monthly Reservations +
+
+
+ +
+
+
+ + + +
+ + +
+
+
+
+
+
+ Upcoming Reservations (Next 7 Days) +
+ + View All + +
+
+
+ {% if upcoming_reservations %} +
+ + + + + + + + + + + + + {% for reservation in upcoming_reservations %} + + + + + + + + + {% endfor %} + +
Date & TimeClassroomSubjectTeacherStatusActions
{{ reservation.start_time.strftime('%Y-%m-%d %H:%M') }}{{ reservation.classroom.code }}{{ reservation.commission.get_full_code() }}{{ reservation.commission.teacher_name }} + + {{ reservation.status.value }} + + + + + +
+
+ {% else %} +
+ +

No upcoming reservations in the next 7 days

+
+ {% endif %} +
+
+
+
+{% endblock %} + +{% block extra_js %} + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/schedule/calendar.html b/app/templates/schedule/calendar.html new file mode 100644 index 0000000..1c688e2 --- /dev/null +++ b/app/templates/schedule/calendar.html @@ -0,0 +1,173 @@ +{% extends "base.html" %} + +{% block title %}Schedule Calendar - Edu-Space Admin{% endblock %} + +{% block content %} +
+
+

+ Schedule Calendar + View & Manage Reservations +

+
+
+ +
+
+
+
+
+ Reservations Calendar +
+
+ +
+
+
+ +
+
+
+ + Confirmed +
+
+ + Pending +
+
+
+ + +
+
+
+ + + +{% endblock %} + +{% block extra_css %} + + +{% endblock %} + +{% block extra_js %} + + +{% endblock %} \ No newline at end of file diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/config.py b/config/config.py new file mode 100644 index 0000000..6836733 --- /dev/null +++ b/config/config.py @@ -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'} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d2f84b6 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..8a2bd70 --- /dev/null +++ b/setup.py @@ -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", + ], +) \ No newline at end of file