diff --git a/app/__init__.py b/app/__init__.py index 6c301f9..b1db117 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -43,8 +43,8 @@ def create_app(): app.register_blueprint(auth_bp) app.register_blueprint(main_bp) - app.register_blueprint(classrooms_bp) - app.register_blueprint(schedule_bp) + app.register_blueprint(classrooms_bp, url_prefix='/classrooms') + app.register_blueprint(schedule_bp, url_prefix='/schedule') # Create database tables with app.app_context(): diff --git a/app/forms/classroom.py b/app/forms/classroom.py index f43def3..1a57377 100644 --- a/app/forms/classroom.py +++ b/app/forms/classroom.py @@ -1,58 +1,44 @@ from flask_wtf import FlaskForm -from wtforms import StringField, IntegerField, SelectField, TextAreaField, SubmitField -from wtforms.validators import DataRequired, NumberRange, Length, Optional +from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField, SelectField +from wtforms.validators import DataRequired, Length, NumberRange, 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) - ]) + # campus_id = SelectField('Campus', coerce=int, validators=[DataRequired()]) building = StringField('Building', validators=[ DataRequired(), - Length(max=50) + Length(min=2, max=100) + ]) + room_number = StringField('Room Number', validators=[ + DataRequired(), + Length(min=1, max=50) ]) floor = IntegerField('Floor', validators=[ DataRequired(), - NumberRange(min=1, max=10) + NumberRange(min=1, max=50) ]) capacity = IntegerField('Capacity', validators=[ DataRequired(), - NumberRange(min=1, max=200) + NumberRange(min=1, max=500) ]) - 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()]) + description = TextAreaField('Description', validators=[ + Optional(), + Length(max=500) + ]) + is_active = BooleanField('Active', default=True) 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()]) + resource_type = SelectField('Resource Type', choices=[ + ('PROJECTOR', 'Projector'), + ('WHITEBOARD', 'Whiteboard'), + ('COMPUTER', 'Computer'), + ('SOUND_SYSTEM', 'Sound System'), + ('MICROPHONE', 'Microphone'), + ('AIR_CONDITIONER', 'Air Conditioner'), + ('OTHER', 'Other') + ], 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 + specifications = TextAreaField('Specifications', validators=[Optional()]) + functional = BooleanField('Functional', default=True) + submit = SubmitField('Save Resource') \ No newline at end of file diff --git a/app/forms/reservation.py b/app/forms/reservation.py index 52e9053..be9e6c7 100644 --- a/app/forms/reservation.py +++ b/app/forms/reservation.py @@ -1,19 +1,40 @@ from flask_wtf import FlaskForm -from wtforms import StringField, SelectField, IntegerField, TextAreaField, SubmitField, DateTimeLocalField -from wtforms.validators import DataRequired, NumberRange, Length, Optional +from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, DateTimeLocalField +from wtforms.validators import DataRequired, Length, NumberRange, Optional, ValidationError +from datetime import datetime, timedelta class ReservationForm(FlaskForm): classroom_id = SelectField('Classroom', coerce=int, validators=[DataRequired()]) - commission_id = SelectField('Commission', coerce=int, validators=[DataRequired()]) + commission_id = SelectField('Commission/Subject', 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') + Length(min=3, max=500) ]) expected_attendees = IntegerField('Expected Attendees', validators=[ DataRequired(), - NumberRange(min=1, max=200, message='Number of attendees must be between 1 and 200') + NumberRange(min=1, max=500) ]) - notes = TextAreaField('Notes', validators=[Optional(), Length(max=1000)]) - submit = SubmitField('Save Reservation') \ No newline at end of file + notes = TextAreaField('Notes', validators=[ + Optional(), + Length(max=1000) + ]) + submit = SubmitField('Make Reservation') + + def validate_end_time(self, field): + if field.data <= self.start_time.data: + raise ValidationError('End time must be after start time.') + + # Check if duration is reasonable (max 8 hours) + if field.data > self.start_time.data + timedelta(hours=8): + raise ValidationError('Reservation cannot exceed 8 hours.') + + def validate_start_time(self, field): + # Don't allow reservations in the past + if field.data < datetime.now(): + raise ValidationError('Start time cannot be in the past.') + + # Don't allow reservations too far in the future (max 3 months) + if field.data > datetime.now() + timedelta(days=90): + raise ValidationError('Reservations cannot be made more than 3 months in advance.') \ No newline at end of file diff --git a/app/models/classroom.py b/app/models/classroom.py index 049bebf..d50aabe 100644 --- a/app/models/classroom.py +++ b/app/models/classroom.py @@ -5,48 +5,58 @@ class Classroom(db.Model): __tablename__ = 'classrooms' id = db.Column(db.Integer, primary_key=True) - campus_id = db.Column(db.Integer, db.ForeignKey('campuses.id'), nullable=False) + # campus_id = db.Column(db.Integer, db.ForeignKey('campuses.id'), nullable=False) building = db.Column(db.String(100), nullable=False) - room_number = db.Column(db.String(50), nullable=False) + code = db.Column(db.String(50), nullable=False) # Changed from room_number + name = db.Column(db.String(100), 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(100)) 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) - # Virtual field for code (combination of building and room_number) + # Virtual field for compatibility - combines building and code @property - def code(self): - return f"{self.building}-{self.room_number}" + def room_number(self): + return self.code - # Relationships - reservations = db.relationship('Reservation', backref='classroom', lazy=True, cascade='all, delete-orphan') + @room_number.setter + def room_number(self, value): + self.code = value - # Resources relationship (教室资源) - resources = db.relationship('ClassroomResource', backref='classroom', lazy=True, cascade='all, delete-orphan') + # Override code property for consistency + @property + def code_display(self): + return f"{self.building}-{self.code}" def to_dict(self): """Convert classroom to dictionary""" return { 'id': self.id, - 'code': self.code, + 'code': self.code_display, 'building': self.building, + 'name': self.name, '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] + 'resources': [resource.to_dict() for resource in self.resources] if hasattr(self, 'resources') else [] } def get_full_location(self): """Get full classroom location""" - return f"{self.building} - Floor {self.floor}" + location = f"{self.building} - Floor {self.floor}" + if self.campus_location: + location += f" ({self.campus_location})" + return location def __repr__(self): - return f'' + return f'' class ClassroomResource(db.Model): __tablename__ = 'classroom_resources' @@ -62,6 +72,9 @@ class ClassroomResource(db.Model): created_at = db.Column(db.DateTime, default=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + # Relationships + classroom = db.relationship('Classroom', backref=db.backref('resources', lazy=True, cascade='all, delete-orphan')) + def to_dict(self): """Convert resource to dictionary""" return { diff --git a/app/models/subject.py b/app/models/subject.py index a89ae36..6f02017 100644 --- a/app/models/subject.py +++ b/app/models/subject.py @@ -10,7 +10,7 @@ class Subject(db.Model): description = db.Column(db.Text) department = db.Column(db.String(100), nullable=False) credits = db.Column(db.Integer, nullable=False) - duration_in_minutes = db.Column(db.Integer, nullable=False) + # duration_in_minutes = db.Column(db.Integer, nullable=False) 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) @@ -27,7 +27,7 @@ class Subject(db.Model): 'description': self.description, 'department': self.department, 'credits': self.credits, - 'duration_minutes': self.duration_in_minutes, + # 'duration_minutes': self.duration_in_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 @@ -48,10 +48,19 @@ class Commission(db.Model): max_students = db.Column(db.Integer, nullable=False) current_students = db.Column(db.Integer, default=0) schedule = db.Column(db.String(255)) - is_active = db.Column(db.Boolean, default=True, nullable=False) + active = db.Column(db.Boolean, default=True) # Changed from is_active to active created_at = db.Column(db.DateTime, default=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + # Virtual property for compatibility + @property + def is_active(self): + return self.active + + @is_active.setter + def is_active(self, value): + self.active = value + # Relationships reservations = db.relationship('Reservation', backref='commission', lazy=True, cascade='all, delete-orphan') teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id]) @@ -76,7 +85,7 @@ class Commission(db.Model): 'max_students': self.max_students, 'current_students': self.current_students, 'schedule': self.schedule, - 'is_active': self.is_active, + 'active': self.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 diff --git a/app/routes/classrooms.py b/app/routes/classrooms.py index b0b2aba..f239a81 100644 --- a/app/routes/classrooms.py +++ b/app/routes/classrooms.py @@ -1,214 +1,232 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify -from flask_login import login_required +from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify +from flask_login import login_required, current_user +from datetime import datetime, date +from app.models.classroom import Classroom +from app.models.user import User +from app.forms.classroom import ClassroomForm 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 +from sqlalchemy import text -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 = Blueprint('classrooms', __name__) @classrooms_bp.route('/add', methods=['GET', 'POST']) @login_required def add_classroom(): - """Add new classroom""" + """Add a new classroom""" form = ClassroomForm() + # TODO: Update when campus model is implemented + # form.campus_id.choices = [(1, 'Main Campus')] + 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: + existing_classroom = Classroom.query.filter_by( + building=form.building.data, + code=form.room_number.data, + floor=form.floor.data + ).first() + + if existing_classroom: + flash('Classroom with this building, room number, and floor already exists.', 'error') + return render_template('classrooms/add.html', form=form, title='Add Classroom') + + classroom = Classroom( + # campus_id=form.campus_id.data, + building=form.building.data, + code=form.room_number.data, + name=f"{form.building.data}-{form.room_number.data}", + floor=form.floor.data, + capacity=form.capacity.data, + description=form.description.data, + is_active=form.is_active.data + ) + db.session.add(classroom) db.session.commit() - flash(f'Classroom {classroom.code} added successfully!', 'success') + + flash(f'Classroom {classroom.code_display} has been created 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') + flash(f'Error creating classroom: {str(e)}', 'error') - return render_template('classrooms/add.html', form=form) + return render_template('classrooms/add.html', form=form, title='Add Classroom') -@classrooms_bp.route('/edit/', methods=['GET', 'POST']) +@classrooms_bp.route('/list') +@login_required +def list_classrooms(): + """List all classrooms""" + try: + # Get filters + page = request.args.get('page', 1, type=int) + active_only = request.args.get('active_only', 'true').lower() == 'true' + search = request.args.get('search', '', type=str) + + # Build query + query = Classroom.query + + if active_only: + query = query.filter_by(is_active=True) + + if search: + query = query.filter( + db.or_( + Classroom.building.ilike(f'%{search}%'), + Classroom.code.ilike(f'%{search}%'), + Classroom.description.ilike(f'%{search}%') + ) + ) + + # Paginate results + classrooms = query.order_by(Classroom.building, Classroom.floor, Classroom.code).paginate( + page=page, per_page=12, error_out=False + ) + + return render_template('classrooms/list.html', + classrooms=classrooms, + active_only=active_only, + search=search, + title='Classrooms') + + except Exception as e: + flash(f'Error loading classrooms: {str(e)}', 'error') + return render_template('classrooms/list.html', classrooms=None, title='Classrooms') + +@classrooms_bp.route('/') @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') +def view_classroom(id): + """View classroom details""" + try: + classroom = Classroom.query.get_or_404(id) + + # Get upcoming reservations for this classroom + from app.models.reservation import Reservation, ReservationStatus + + upcoming_reservations = Reservation.query.filter( + Reservation.classroom_id == id, + Reservation.start_time > datetime.utcnow(), + Reservation.status == ReservationStatus.CONFIRMED + ).order_by(Reservation.start_time).limit(10).all() + + return render_template('classrooms/view.html', + classroom=classroom, + upcoming_reservations=upcoming_reservations, + title=f'Classroom {classroom.code_display}') + + except Exception as e: + flash(f'Error loading classroom: {str(e)}', 'error') return redirect(url_for('classrooms.list_classrooms')) - + +@classrooms_bp.route('//edit', methods=['GET', 'POST']) +@login_required +def edit_classroom(id): + """Edit classroom""" + classroom = Classroom.query.get_or_404(id) form = ClassroomForm(obj=classroom) + # TODO: Update when campus model is implemented + # form.campus_id.choices = [(1, 'Main Campus')] + if form.validate_on_submit(): try: - classroom.code = form.code.data - classroom.name = form.name.data + # Check for duplicates (excluding current classroom) + existing_classroom = Classroom.query.filter( + Classroom.building == form.building.data, + Classroom.code == form.room_number.data, + Classroom.floor == form.floor.data, + Classroom.id != id + ).first() + + if existing_classroom: + flash('Classroom with this building, room number, and floor already exists.', 'error') + return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom') + + # Update classroom + # classroom.campus_id = form.campus_id.data classroom.building = form.building.data + classroom.code = form.room_number.data + classroom.name = f"{form.building.data}-{form.room_number.data}" classroom.floor = form.floor.data classroom.capacity = form.capacity.data classroom.description = form.description.data - classroom.campus_location = form.campus_location.data + classroom.is_active = form.is_active.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)) + + flash(f'Classroom {classroom.code_display} has been 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') + flash(f'Error updating classroom: {str(e)}', 'error') - return render_template('classrooms/edit.html', form=form, classroom=classroom) + return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom') -@classrooms_bp.route('/view/') +@classrooms_bp.route('//toggle', methods=['POST']) @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)) +def toggle_classroom(id): + """Toggle classroom active status""" + classroom = Classroom.query.get_or_404(id) try: - classroom.is_active = False + classroom.is_active = not classroom.is_active classroom.updated_at = datetime.utcnow() db.session.commit() - flash(f'Classroom {classroom.code} has been deactivated.', 'success') + + status = 'activated' if classroom.is_active else 'deactivated' + flash(f'Classroom {classroom.code_display} has been {status} successfully!', 'success') + except Exception as e: db.session.rollback() - flash('Error deactivating classroom.', 'error') + flash(f'Error updating classroom status: {str(e)}', '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) + return redirect(request.referrer or url_for('classrooms.list_classrooms')) +# API endpoints for AJAX requests @classrooms_bp.route('/api/available') @login_required -def api_available_classrooms(): - """API endpoint to get available classrooms""" +def get_available_classrooms(): + """Get available classrooms for a specific time slot""" 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 + return jsonify({'error': 'Start time and end time are required'}), 400 try: + from datetime import datetime + from app.models.reservation import Reservation, ReservationStatus + 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 + + # Find all classrooms + all_classrooms = Classroom.query.filter_by(is_active=True).all() + available_classrooms = [] + + for classroom in all_classrooms: + # Check if classroom has conflicting reservations + conflicts = Reservation.query.filter( + Reservation.classroom_id == classroom.id, + Reservation.status == ReservationStatus.CONFIRMED, + Reservation.start_time < end_dt, + Reservation.end_time > start_dt + ).all() + + if not conflicts: + available_classrooms.append({ + 'id': classroom.id, + 'code': classroom.code, + 'building': classroom.building, + 'room_number': classroom.code, + 'floor': classroom.floor, + 'capacity': classroom.capacity, + 'code_display': classroom.code_display + }) + + return jsonify({'classrooms': available_classrooms}) + + except Exception as e: + return jsonify({'error': str(e)}), 500 \ No newline at end of file diff --git a/app/routes/schedule.py b/app/routes/schedule.py index 47449be..7f17e22 100644 --- a/app/routes/schedule.py +++ b/app/routes/schedule.py @@ -1,277 +1,330 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify +from flask import Blueprint, render_template, redirect, url_for, flash, request, 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.models.subject import Commission from app.forms.reservation import ReservationForm -from datetime import datetime, timedelta -from sqlalchemy import func, or_ +from app import db +from datetime import datetime, timedelta, date +from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, DateTimeLocalField +from wtforms.validators import DataRequired, Length, NumberRange, Optional -schedule_bp = Blueprint('schedule', __name__, url_prefix='/schedule') +schedule_bp = Blueprint('schedule', __name__) -@schedule_bp.route('/') +@schedule_bp.route('/add', methods=['GET', 'POST']) @login_required -def calendar_view(): - """Calendar view of reservations""" - return render_template('schedule/calendar.html') +def add_reservation(): + """Add a new reservation""" + form = ReservationForm() + + # Populate form choices + form.classroom_id.choices = [(c.id, f"{c.code_display} (Capacity: {c.capacity})") + for c in Classroom.query.filter_by(is_active=True).order_by(Classroom.building, Classroom.code).all()] + form.commission_id.choices = [(c.id, c.get_full_code()) + for c in Commission.query.filter(Commission.active == True).order_by(Commission.code).all()] + + if form.validate_on_submit(): + try: + # Check for time conflicts + conflicting_reservations = Reservation.find_conflicts( + form.classroom_id.data, + form.start_time.data, + form.end_time.data + ) + + if conflicting_reservations: + flash('This classroom is already reserved for the selected time slot.', 'error') + # Get classrooms for the template + classroom_list = Classroom.query.filter_by(is_active=True).order_by(Classroom.building, Classroom.code).all() + return render_template('schedule/add.html', form=form, classroom_list=classroom_list, title='New Reservation') + + # Check capacity + classroom = Classroom.query.get(form.classroom_id.data) + if form.expected_attendees.data > classroom.capacity: + flash(f'Expected attendees ({form.expected_attendees.data}) exceeds classroom capacity ({classroom.capacity}).', 'error') + # Get classrooms for the template + classroom_list = Classroom.query.filter_by(is_active=True).order_by(Classroom.building, Classroom.code).all() + return render_template('schedule/add.html', form=form, classroom_list=classroom_list, title='New Reservation') + + # Create reservation + 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 + ) + + db.session.add(reservation) + db.session.commit() + + flash('Reservation created successfully! It will be confirmed after review.', 'success') + return redirect(url_for('schedule.list_reservations')) + + except Exception as e: + db.session.rollback() + flash(f'Error creating reservation: {str(e)}', 'error') + + # Get classrooms for the template + classroom_list = Classroom.query.filter_by(is_active=True).order_by(Classroom.building, Classroom.code).all() + + return render_template('schedule/add.html', form=form, classroom_list=classroom_list, title='New Reservation') @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, f'{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, f'{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, f'{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, f'{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') + page = request.args.get('page', 1, type=int) + status_filter = request.args.get('status', 'all') + date_from = request.args.get('date_from') + date_to = request.args.get('date_to') + + # Build query + query = Reservation.query + + # Apply filters + if status_filter != 'all': + try: + query = query.filter_by(status=ReservationStatus[status_filter.upper()]) + except KeyError: + pass + + if date_from: + try: + from_date = datetime.strptime(date_from, '%Y-%m-%d') + query = query.filter(Reservation.start_time >= from_date) + except ValueError: + pass + + if date_to: + try: + to_date = datetime.strptime(date_to, '%Y-%m-%d') + timedelta(days=1) + query = query.filter(Reservation.start_time < to_date) + except ValueError: + pass + + # Order by start time descending + reservations = query.order_by(Reservation.start_time.desc()).paginate( + page=page, per_page=20, error_out=False + ) + + return render_template('schedule/list.html', + reservations=reservations, + status_filter=status_filter, + date_from=date_from, + date_to=date_to, + title='Reservations') + 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) + flash(f'Error loading reservations: {str(e)}', 'error') + return render_template('schedule/list.html', reservations=None, title='Reservations') @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()) + try: + today_start = datetime.combine(date.today(), datetime.min.time()) + today_end = datetime.combine(date.today(), datetime.max.time()) + + # Get today's reservations + reservations = Reservation.query.filter( + Reservation.start_time >= today_start, + Reservation.start_time <= today_end + ).order_by(Reservation.start_time).all() + + # Group by time blocks for better display + time_blocks = {} + + for reservation in reservations: + hour_key = reservation.start_time.hour + + if hour_key not in time_blocks: + time_blocks[hour_key] = [] + + time_blocks[hour_key].append({ + 'reservation': reservation, + 'classroom': reservation.classroom, + 'commission': reservation.commission, + 'user': reservation.user + }) + + # Get quick stats + stats = { + 'total_reservations': len(reservations), + 'confirmed': len([r for r in reservations if r.status == ReservationStatus.CONFIRMED]), + 'pending': len([r for r in reservations if r.status == ReservationStatus.PENDING]), + 'cancelled': len([r for r in reservations if r.status == ReservationStatus.CANCELLED]), + 'total_classrooms': Classroom.query.filter_by(is_active=True).count(), + 'occupied_classrooms': len(set(r.classroom_id for r in reservations + if r.status == ReservationStatus.CONFIRMED)) + } + + return render_template('schedule/today.html', + time_blocks=time_blocks, + reservations=reservations, + stats=stats, + today=date.today(), + title='Today\'s Schedule') + + except Exception as e: + flash(f'Error loading today\'s schedule: {str(e)}', 'error') + return render_template('schedule/today.html', + time_blocks={}, + reservations=[], + stats={}, + today=date.today(), + title='Today\'s Schedule') + +@schedule_bp.route('//confirm', methods=['POST']) +@login_required +def confirm_reservation(id): + """Confirm a reservation""" + reservation = Reservation.query.get_or_404(id) - 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() + try: + # Check if reservation can be confirmed + if reservation.status != ReservationStatus.PENDING: + flash('Only pending reservations can be confirmed.', 'error') + return redirect(request.referrer) + + # Check for conflicts again before confirming + conflicts = Reservation.find_conflicts( + reservation.classroom_id, + reservation.start_time, + reservation.end_time + ) + + if conflicts: + flash('Cannot confirm: Time slot is already occupied.', 'error') + return redirect(request.referrer) + + reservation.status = ReservationStatus.CONFIRMED + db.session.commit() + + flash('Reservation confirmed successfully!', 'success') + + except Exception as e: + db.session.rollback() + flash(f'Error confirming reservation: {str(e)}', 'error') - return render_template('schedule/today.html', reservations=reservations) \ No newline at end of file + return redirect(request.referrer) + +@schedule_bp.route('//cancel', methods=['POST']) +@login_required +def cancel_reservation(id): + """Cancel a reservation""" + reservation = Reservation.query.get_or_404(id) + + try: + # Check if reservation can be cancelled + if not reservation.can_be_cancelled(): + flash('Reservations can only be cancelled at least 24 hours before start time.', 'error') + return redirect(request.referrer) + + reservation.status = ReservationStatus.CANCELLED + db.session.commit() + + flash('Reservation cancelled successfully!', 'success') + + except Exception as e: + db.session.rollback() + flash(f'Error cancelling reservation: {str(e)}', 'error') + + return redirect(request.referrer) + +@schedule_bp.route('/calendar') +@login_required +def calendar_view(): + """Show calendar view""" + return render_template('schedule/calendar.html', title='Calendar View') + +# API endpoints +@schedule_bp.route('/calendar-data') +@login_required +def calendar_data(): + """Get calendar events data for FullCalendar""" + try: + # Get date range from query parameters + start = request.args.get('start') + end = request.args.get('end') + + if start and end: + from datetime import datetime + start_date = datetime.fromisoformat(start) + end_date = datetime.fromisoformat(end) + else: + # Default to current month + today = date.today() + start_date = datetime.combine(today.replace(day=1), datetime.min.time()) + next_month = today.replace(day=28) + timedelta(days=4) # Next month + end_date = datetime.combine(next_month.replace(day=1), datetime.max.time()) + + # Get reservations in the date range + reservations = Reservation.query.filter( + Reservation.start_time >= start_date, + Reservation.start_time <= end_date, + Reservation.status.in_([ReservationStatus.CONFIRMED, ReservationStatus.PENDING]) + ).order_by(Reservation.start_time).all() + + events = [] + for reservation in reservations: + events.append({ + 'id': reservation.id, + 'title': f"{reservation.commission.get_full_code() if reservation.commission else 'No Commission'} - {reservation.classroom.code_display}", + 'start': reservation.start_time.isoformat(), + 'end': reservation.end_time.isoformat(), + 'url': f"/schedule/{reservation.id}", # For event clicking + 'className': f'fc-event-{reservation.status.value.lower()}', + 'extendedProps': { + 'classroom': reservation.classroom.code_display if reservation.classroom else 'Unknown', + 'commission': reservation.commission.get_full_code() if reservation.commission else 'No Commission', + 'purpose': reservation.purpose or 'No purpose', + 'attendees': reservation.expected_attendees, + 'status': reservation.status.value, + 'notes': reservation.notes or '' + } + }) + + return jsonify(events) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@schedule_bp.route('/api/today-events') +@login_required +def api_today_events(): + """Get today's events as JSON""" + try: + today_start = datetime.combine(date.today(), datetime.min.time()) + today_end = datetime.combine(date.today(), datetime.max.time()) + + reservations = Reservation.query.filter( + Reservation.start_time >= today_start, + Reservation.start_time <= today_end, + Reservation.status == ReservationStatus.CONFIRMED + ).order_by(Reservation.start_time).all() + + events = [] + for reservation in reservations: + events.append({ + 'id': reservation.id, + 'title': f"{reservation.commission.get_full_code()} - {reservation.classroom.code_display}", + 'start': reservation.start_time.isoformat(), + 'end': reservation.end_time.isoformat(), + 'extendedProps': { + 'classroom': reservation.classroom.code_display, + 'purpose': reservation.purpose, + 'attendees': reservation.expected_attendees, + 'status': reservation.status.value + } + }) + + return jsonify({'events': events}) + + except Exception as e: + return jsonify({'error': str(e)}), 500 \ No newline at end of file diff --git a/app/templates/classrooms/add.html b/app/templates/classrooms/add.html new file mode 100644 index 0000000..e047abd --- /dev/null +++ b/app/templates/classrooms/add.html @@ -0,0 +1,198 @@ +{% extends "base.html" %} + +{% block title %}Add Classroom - Edu-Space Admin{% endblock %} + +{% block content %} +
+
+
+
+

+ Add Classroom +

+ + Back to Classrooms + +
+
+
+ +
+
+
+
+
+ Classroom Information +
+
+
+
+ {{ form.hidden_tag() }} + +
+
+ {{ form.building.label(class="form-label") }} + {{ form.building(class="form-control", placeholder="A") }} + {% if form.building.errors %} +
+ {{ form.building.errors[0] }} +
+ {% endif %} +
+ +
+ {{ form.room_number.label(class="form-label") }} + {{ form.room_number(class="form-control", placeholder="101") }} + {% if form.room_number.errors %} +
+ {{ form.room_number.errors[0] }} +
+ {% endif %} +
+ +
+ {{ form.floor.label(class="form-label") }} + {{ form.floor(class="form-control") }} + {% if form.floor.errors %} +
+ {{ form.floor.errors[0] }} +
+ {% endif %} +
+
+ +
+
+ {{ form.capacity.label(class="form-label") }} + {{ form.capacity(class="form-control", placeholder="30") }} + {% if form.capacity.errors %} +
+ {{ form.capacity.errors[0] }} +
+ {% endif %} +
+ +
+ {{ form.is_active.label(class="form-check-label") }} +
+ {{ form.is_active(class="form-check-input") }} +
+
+
+ +
+ {{ form.description.label(class="form-label") }} + {{ form.description(class="form-control", rows="3", placeholder="Optional: Add any additional details about this classroom...") }} + {% if form.description.errors %} +
+ {{ form.description.errors[0] }} +
+ {% endif %} +
+ + +
+
+ Preview +
+
+

Fill in the form to see a preview of the classroom code: + - +

+

Full location: + - +

+
+
+ +
+ + Cancel + + {{ form.submit(class="btn btn-primary") }} +
+
+
+
+
+
+ + +
+
+
+
+
+ Resources (Optional) +
+
+
+

You can add classroom resources after creating the classroom.

+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/classrooms/list.html b/app/templates/classrooms/list.html index a71c432..3212c7f 100644 --- a/app/templates/classrooms/list.html +++ b/app/templates/classrooms/list.html @@ -3,124 +3,130 @@ {% block title %}Classrooms - Edu-Space Admin{% endblock %} {% block content %} -
+
+
-

- Classrooms - Management -

-
-
- - -
-
-
-
- -
-
- -
-
- - - Reset - -
-
+
+ + +
+
+
+
+
+
+ +
+
+
+ + +
+
+
+ + + Clear + +
+
+
+
-
+{% if classrooms and classrooms.items %} +
{% for classroom in classrooms.items %} -
+
-
-
-
-
{{ classroom.code }}
-

{{ classroom.name }}

-
- -
-
-
+
+
+
+ {{ classroom.code }} +
+

+ {{ classroom.get_full_location() }} +

+
+ + {% if classroom.is_active %}Active{% else %}Inactive{% endif %} + +
+ +
-
- - {{ classroom.building }} -
+ Capacity: + {{ classroom.capacity }}
-
- - Floor {{ classroom.floor }} -
-
-
-
- - {{ classroom.capacity }} seats -
-
-
-
- - {{ classroom.campus_location }} -
+ Building: + {{ classroom.building }}
{% if classroom.description %} -
-

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

-
+

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

{% endif %} - -
- - Active - + + +
+ + + {% if classroom.resources %} + {{ classroom.resources|length }} resources + {% else %} + No resources registered + {% endif %} +
+ @@ -130,12 +136,12 @@ {% if classrooms.pages > 1 %} -