Source code for app.routes

import os
import json
from datetime import datetime, timedelta
from flask import render_template, redirect, url_for, session, request, flash
from flask_login import current_user, login_user, logout_user, login_required
from google_auth_oauthlib.flow import Flow
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
import google.auth.transport.requests
from app import app, db, login
from app.models import User, Course, CourseTag, ItemTag, MutedItem, UserTag

# Helper for file icons
[docs] def get_file_icon(mime_type, title=None): """ Determines the appropriate Font Awesome icon class for a given file MIME type. Args: mime_type (str): The MIME type of the file (e.g., 'application/pdf'). title (str, optional): The title of the file (unused, but kept for compatibility). Returns: str: A string containing the CSS classes for the icon (e.g., 'file-pdf text-red-500'). """ if not mime_type: return 'file text-gray-500' mime_type = mime_type.lower() if 'pdf' in mime_type: return 'file-pdf text-red-500' elif 'image' in mime_type: return 'file-image text-purple-500' elif 'video' in mime_type: return 'file-video text-red-600' elif 'audio' in mime_type: return 'file-audio text-yellow-500' elif 'sheet' in mime_type or 'excel' in mime_type or 'spreadsheet' in mime_type: return 'file-excel text-green-500' elif 'presentation' in mime_type or 'powerpoint' in mime_type or 'slides' in mime_type: return 'file-powerpoint text-orange-500' elif 'document' in mime_type or 'word' in mime_type: return 'file-word text-blue-500' elif 'form' in mime_type: return 'file-alt text-purple-600' else: return 'file text-gray-500'
app.jinja_env.globals.update(get_file_icon=get_file_icon)
[docs] @login.user_loader def load_user(id): """ Flask-Login user loader callback. Args: id (str): The user ID from the session. Returns: User: The User object corresponding to the ID. """ return User.query.get(int(id))
[docs] @app.route('/') @app.route('/index') def index(): """ Renders the main dashboard (index) page. Fetches the user's courses from Google Classroom, merges them with local database overrides, checks for new assignments, and handles display sorting and filtering. Returns: str: Rendered HTML template for the dashboard. """ courses = [] if current_user.is_authenticated: # Build credentials from stored user data credentials = Credentials( token=current_user.access_token, refresh_token=current_user.refresh_token, token_uri=current_user.token_uri, client_id=current_user.client_id, client_secret=current_user.client_secret, scopes=current_user.scopes.split(',') if current_user.scopes else [] ) try: # Get courses classroom_service = build('classroom', 'v1', credentials=credentials) results = classroom_service.courses().list(studentId='me').execute() google_courses = results.get('courses', []) # Check for new assignments (last 24 hours) try: current_time = datetime.utcnow() yesterday = current_time - timedelta(days=1) # Initialize seen list in session if not present if 'seen_assignments' not in session: session['seen_assignments'] = [] # Use a set for faster lookups and to avoid duplicates in session seen_ids = set(session['seen_assignments']) new_seen_ids = set() def check_new_assignments_callback(request_id, response, exception): if exception: print(f"Error checking assignments for {request_id}: {exception}") else: course_work = response.get('courseWork', []) for work in course_work: if work.get('workType') == 'ASSIGNMENT': creation_time_str = work.get('creationTime') if creation_time_str: try: creation_time_str = creation_time_str.replace('Z', '') if '.' in creation_time_str: creation_time_str = creation_time_str.split('.')[0] creation_dt = datetime.fromisoformat(creation_time_str) if creation_dt > yesterday: work_id = work.get('id') # Only notify if we haven't seen this assignment in this session if work_id not in seen_ids: course_name = "Class" for c in google_courses: if c['id'] == request_id: course_name = c.get('name') break flash({ 'text': f"New Assignment: {work.get('title')} in {course_name}", 'url': url_for('course_stream', course_id=request_id) }, 'info') new_seen_ids.add(work_id) except ValueError: pass batch = classroom_service.new_batch_http_request(callback=check_new_assignments_callback) for course in google_courses: if course.get('courseState') == 'ACTIVE': batch.add(classroom_service.courses().courseWork().list( courseId=course['id'], orderBy='updateTime desc', pageSize=5 ), request_id=course['id']) if google_courses: batch.execute() # Update session with new seen IDs if new_seen_ids: seen_ids.update(new_seen_ids) session['seen_assignments'] = list(seen_ids) session.modified = True except Exception as e: print(f"Error checking new assignments: {e}") # Merge with local data for g_course in google_courses: local_course = Course.query.filter_by(user_id=current_user.id, google_course_id=g_course['id']).first() # Create a display object (dict) display_course = g_course.copy() display_course['is_archived'] = False # Determine if archived (Google state or Local override) if g_course.get('courseState') == 'ARCHIVED': display_course['is_archived'] = True if local_course: if local_course.custom_name: display_course['name'] = local_course.custom_name if local_course.custom_section: display_course['section'] = local_course.custom_section if local_course.custom_code: display_course['enrollmentCode'] = local_course.custom_code # Override enrollment code for display if local_course.is_archived: display_course['is_archived'] = True # Add other custom fields if needed for template display_course['custom_banner'] = local_course.custom_banner display_course['custom_icon'] = local_course.custom_icon display_course['custom_teacher_name'] = local_course.custom_teacher_name display_course['cached_teacher_name'] = local_course.cached_teacher_name display_course['display_order'] = local_course.display_order display_course['tags'] = local_course.user_tags else: display_course['tags'] = [] # Fetch teacher name if missing if not display_course.get('custom_teacher_name') and not display_course.get('cached_teacher_name'): try: # We need to fetch the teacher profile. # Note: This can be slow for many courses. Ideally, do this asynchronously or in batch. # For now, we'll do it on demand and cache it. owner_id = g_course.get('ownerId') if owner_id: user_profile = classroom_service.userProfiles().get(userId=owner_id).execute() teacher_name = user_profile.get('name', {}).get('fullName') if not local_course: local_course = Course(user_id=current_user.id, google_course_id=g_course['id']) db.session.add(local_course) local_course.cached_teacher_name = teacher_name db.session.commit() display_course['cached_teacher_name'] = teacher_name except HttpError as e: if e.resp.status == 403: # Check for missing scopes required_scopes = set(app.config['GOOGLE_SCOPES']) current_scopes = set(current_user.scopes.split(',')) if current_user.scopes else set() missing_scopes = required_scopes - current_scopes if missing_scopes: print(f"Missing scopes for teacher fetch: {missing_scopes}") logout_user() flash('New permissions are required to view teacher names. Please log in again.', 'info') return redirect(url_for('login')) print(f"Error fetching teacher for {g_course['id']}: {e}") except Exception as e: print(f"Error fetching teacher for {g_course['id']}: {e}") # Final Teacher Name Logic display_course['teacher_name'] = display_course.get('custom_teacher_name') or display_course.get('cached_teacher_name') or 'Unknown Teacher' if not display_course['is_archived']: courses.append(display_course) except Exception as e: # Token might be expired and refresh failed, or other API error flash(f'Error fetching courses: {str(e)}', 'error') # Optionally force re-login if token is invalid # Sort courses by display_order courses.sort(key=lambda x: x.get('display_order', 0)) # Get user tags for filtering user_tags = [] if current_user.is_authenticated: user_tags = UserTag.query.filter_by(user_id=current_user.id).all() return render_template('index.html', title='Home', courses=courses, user_tags=user_tags)
[docs] @app.route('/archived') @login_required def archived_courses(): """ Renders the archived courses page. Displays courses that are either archived in Google Classroom or locally archived by the user. Returns: str: Rendered HTML template for archived courses. """ courses = [] # Build credentials from stored user data credentials = Credentials( token=current_user.access_token, refresh_token=current_user.refresh_token, token_uri=current_user.token_uri, client_id=current_user.client_id, client_secret=current_user.client_secret, scopes=current_user.scopes.split(',') if current_user.scopes else [] ) try: # Get courses classroom_service = build('classroom', 'v1', credentials=credentials) results = classroom_service.courses().list(studentId='me').execute() google_courses = results.get('courses', []) # Merge with local data for g_course in google_courses: local_course = Course.query.filter_by(user_id=current_user.id, google_course_id=g_course['id']).first() display_course = g_course.copy() display_course['is_archived'] = False if g_course.get('courseState') == 'ARCHIVED': display_course['is_archived'] = True if local_course: if local_course.custom_name: display_course['name'] = local_course.custom_name if local_course.custom_section: display_course['section'] = local_course.custom_section if local_course.custom_banner: display_course['custom_banner'] = local_course.custom_banner if local_course.is_archived: display_course['is_archived'] = True display_course['teacher_name'] = local_course.custom_teacher_name or local_course.cached_teacher_name if display_course['is_archived']: courses.append(display_course) except Exception as e: flash(f'Error fetching archived courses: {str(e)}', 'error') return render_template('index.html', title='Archived Classes', courses=courses, is_archived_view=True)
[docs] @app.route('/missing') @login_required def missing_assignments(): """ Renders the missing assignments page. Fetches all coursework from active courses, checks submission status, and identifies missing items. Filters out items that the user has locally muted. Returns: str: Rendered HTML template for missing assignments. """ assignments = [] # Build credentials credentials = Credentials( token=current_user.access_token, refresh_token=current_user.refresh_token, token_uri=current_user.token_uri, client_id=current_user.client_id, client_secret=current_user.client_secret, scopes=current_user.scopes.split(',') if current_user.scopes else [] ) try: service = build('classroom', 'v1', credentials=credentials) # 1. Get all active courses results = service.courses().list(studentId='me', courseStates=['ACTIVE']).execute() courses = results.get('courses', []) if not courses: return render_template('missing_assignments.html', title='Missing Assignments', assignments=[]) # Prepare data structures for batch results all_course_work = {} # course_id -> [work] all_submissions = {} # course_id -> [submission] # Callbacks def cw_callback(request_id, response, exception): if exception: print(f"Error fetching coursework for {request_id}: {exception}") else: all_course_work[request_id] = response.get('courseWork', []) def sub_callback(request_id, response, exception): if exception: print(f"Error fetching submissions for {request_id}: {exception}") else: all_submissions[request_id] = response.get('studentSubmissions', []) # 2. Batch fetch CourseWork batch_cw = service.new_batch_http_request(callback=cw_callback) for course in courses: batch_cw.add(service.courses().courseWork().list(courseId=course['id']), request_id=course['id']) batch_cw.execute() # 3. Batch fetch Submissions batch_sub = service.new_batch_http_request(callback=sub_callback) for course in courses: batch_sub.add(service.courses().courseWork().studentSubmissions().list( courseId=course['id'], courseWorkId='-', userId='me', states=['CREATED', 'RECLAIMED_BY_STUDENT'] ), request_id=course['id']) batch_sub.execute() # 4. Process Data now = datetime.utcnow() # Fetch muted items muted_items = MutedItem.query.filter_by(user_id=current_user.id).all() muted_ids = {item.google_item_id for item in muted_items} for course in courses: c_id = course['id'] course_work = all_course_work.get(c_id, []) submissions = all_submissions.get(c_id, []) if not course_work: continue # Map submissions to coursework ID submission_map = {s['courseWorkId']: s for s in submissions} for work in course_work: if work['id'] in submission_map: # It is not turned in. assignment = work.copy() assignment['courseName'] = course['name'] assignment['isMissing'] = False assignment['isMuted'] = work['id'] in muted_ids if 'dueDate' in work: # Parse due date due = work['dueDate'] # {year, month, day} time = work.get('dueTime', {'hours': 23, 'minutes': 59}) try: due_dt = datetime(due['year'], due['month'], due['day'], time.get('hours', 0), time.get('minutes', 0)) assignment['dueDate'] = due_dt.strftime('%Y-%m-%d %H:%M') if due_dt < now: assignment['isMissing'] = True except ValueError: assignment['dueDate'] = None else: assignment['dueDate'] = None assignments.append(assignment) except Exception as e: flash(f'Error fetching missing assignments: {str(e)}', 'error') # Sort: Missing first, then by due date def sort_key(a): is_missing = 0 if a.get('isMissing') else 1 due = a.get('dueDate') or '9999-99-99' return (is_missing, due) assignments.sort(key=sort_key) return render_template('missing_assignments.html', title='Missing Assignments', assignments=assignments)
[docs] @app.route('/update_course_order', methods=['POST']) @login_required def update_course_order(): """ Updates the display order of courses based on drag-and-drop input. Expects a JSON payload with an 'order' list containing Google Course IDs. Returns: dict: Status dictionary {'status': 'success'}. """ data = request.get_json() ordered_ids = data.get('order', []) for index, google_id in enumerate(ordered_ids): course = Course.query.filter_by(user_id=current_user.id, google_course_id=google_id).first() if not course: course = Course(user_id=current_user.id, google_course_id=google_id) db.session.add(course) course.display_order = index db.session.commit() return {'status': 'success'}
[docs] @app.route('/course/<course_id>/edit', methods=['GET', 'POST']) @login_required def edit_course(course_id): """ Handles editing of course settings (name, section, tags, etc.). Args: course_id (str): The Google Course ID. Returns: str: Rendered HTML template for the edit form or a redirect. """ # Find or create local course record course = Course.query.filter_by(user_id=current_user.id, google_course_id=course_id).first() if not course: course = Course(user_id=current_user.id, google_course_id=course_id) db.session.add(course) if request.method == 'POST': course.custom_name = request.form.get('custom_name') course.custom_section = request.form.get('custom_section') course.custom_code = request.form.get('custom_code') course.custom_banner = request.form.get('custom_banner') course.custom_teacher_name = request.form.get('custom_teacher_name') if 'display_order' in request.form: try: course.display_order = int(request.form.get('display_order', 0)) except ValueError: course.display_order = 0 course.is_archived = 'is_archived' in request.form # Handle Tags tag_names = request.form.get('tags', '').split(',') tag_names = [t.strip() for t in tag_names if t.strip()] # Clear existing tags course.user_tags = [] for name in tag_names: # Check if tag exists for user tag = UserTag.query.filter_by(user_id=current_user.id, name=name).first() if not tag: # Check limit of 12 tags per user if UserTag.query.filter_by(user_id=current_user.id).count() >= 12: flash(f'Tag limit reached (12). Could not create tag "{name}".', 'warning') continue tag = UserTag(user_id=current_user.id, name=name) db.session.add(tag) if tag not in course.user_tags: course.user_tags.append(tag) db.session.commit() # Cleanup unused tags user_tags = UserTag.query.filter_by(user_id=current_user.id).all() for tag in user_tags: if not tag.courses: db.session.delete(tag) db.session.commit() flash('Course updated successfully!', 'success') return redirect(url_for('index')) # For GET, we might want to fetch the original Google name to show as placeholder # But for now, let's just show the form with current local values return render_template('edit_course.html', course=course, google_course_id=course_id)
[docs] @app.route('/course/<course_id>/tags', methods=['POST']) @login_required def create_tag(course_id): """ Creates a new tag for a specific course (Legacy/Specific Tag). Args: course_id (str): The Google Course ID. Returns: redirect: Redirects back to the course stream. """ # Ensure local course exists course = Course.query.filter_by(user_id=current_user.id, google_course_id=course_id).first() if not course: course = Course(user_id=current_user.id, google_course_id=course_id) db.session.add(course) db.session.commit() # Commit to get ID # Check limit if len(course.tags) >= 6: flash('Limit of 6 tags per class reached.', 'error') return redirect(url_for('course_stream', course_id=course_id)) name = request.form.get('name') color = request.form.get('color', 'blue') if name: tag = CourseTag(course_id=course.id, name=name, color=color) db.session.add(tag) try: db.session.commit() flash('Tag created!', 'success') except: db.session.rollback() flash('Tag already exists.', 'error') return redirect(url_for('course_stream', course_id=course_id))
[docs] @app.route('/course/<course_id>/tags/<int:tag_id>/delete', methods=['POST']) @login_required def delete_tag(course_id, tag_id): """ Deletes a specific course tag. Args: course_id (str): The Google Course ID. tag_id (int): The ID of the tag to delete. Returns: redirect: Redirects back to the course stream. """ tag = CourseTag.query.get_or_404(tag_id) # Verify ownership via course course = Course.query.get(tag.course_id) if course.user_id != current_user.id or course.google_course_id != course_id: flash('Unauthorized', 'error') return redirect(url_for('index')) db.session.delete(tag) db.session.commit() flash('Tag deleted.', 'success') return redirect(url_for('course_stream', course_id=course_id))
[docs] @app.route('/course/<course_id>/items/<item_id>/tags', methods=['POST']) @login_required def toggle_item_tag(course_id, item_id): """ Toggles a tag on a specific stream item (assignment). Args: course_id (str): The Google Course ID. item_id (str): The Google Item ID (assignment ID). Returns: redirect: Redirects back to the course stream. """ tag_id = request.form.get('tag_id') if not tag_id: return 'Missing tag_id', 400 tag = CourseTag.query.get(int(tag_id)) if not tag: return 'Tag not found', 404 # Check if already assigned assignment = ItemTag.query.filter_by(tag_id=tag.id, google_item_id=item_id).first() if assignment: db.session.delete(assignment) action = 'removed' else: assignment = ItemTag(tag_id=tag.id, google_item_id=item_id) db.session.add(assignment) action = 'added' db.session.commit() return redirect(url_for('course_stream', course_id=course_id))
[docs] @app.route('/mute_assignment/<item_id>', methods=['POST']) @login_required def toggle_mute_assignment(item_id): """ Toggles the muted state of an assignment (hides/shows it in Missing Assignments). Args: item_id (str): The Google Item ID. Returns: dict: JSON status if AJAX request. redirect: Redirects to missing assignments page if standard request. """ muted = MutedItem.query.filter_by(user_id=current_user.id, google_item_id=item_id).first() if muted: db.session.delete(muted) status = 'unmuted' else: muted = MutedItem(user_id=current_user.id, google_item_id=item_id) db.session.add(muted) status = 'muted' db.session.commit() # Return JSON if AJAX, else redirect if request.is_json: return {'status': status} return redirect(url_for('missing_assignments'))
[docs] @app.route('/course/<course_id>') @login_required def course_stream(course_id): """ Renders the stream view for a specific course. Fetches announcements, coursework, and materials from Google Classroom. Merges with local tag data. Args: course_id (str): The Google Course ID. Returns: str: Rendered HTML template for the course stream. """ # Build credentials credentials = Credentials( token=current_user.access_token, refresh_token=current_user.refresh_token, token_uri=current_user.token_uri, client_id=current_user.client_id, client_secret=current_user.client_secret, scopes=current_user.scopes.split(',') if current_user.scopes else [] ) # Force refresh if needed (though google-auth usually handles this) if credentials.expired: request_adapter = google.auth.transport.requests.Request() credentials.refresh(request_adapter) # Update DB with new token current_user.access_token = credentials.token db.session.commit() service = build('classroom', 'v1', credentials=credentials) # Fetch Course Details (for banner/name) try: google_course = service.courses().get(id=course_id).execute() except Exception as e: flash(f'Error fetching course: {str(e)}', 'error') return redirect(url_for('index')) # Apply local overrides local_course = Course.query.filter_by(user_id=current_user.id, google_course_id=course_id).first() course = google_course.copy() tags = [] item_tags_map = {} if local_course: if local_course.custom_name: course['name'] = local_course.custom_name if local_course.custom_section: course['section'] = local_course.custom_section if local_course.custom_banner: course['custom_banner'] = local_course.custom_banner if local_course.custom_code: course['enrollmentCode'] = local_course.custom_code tags = local_course.tags tag_ids = [t.id for t in tags] if tag_ids: assignments = ItemTag.query.filter(ItemTag.tag_id.in_(tag_ids)).all() for a in assignments: if a.google_item_id not in item_tags_map: item_tags_map[a.google_item_id] = [] tag_obj = next((t for t in tags if t.id == a.tag_id), None) if tag_obj: item_tags_map[a.google_item_id].append(tag_obj) # Fetch Stream Items stream_items = [] # Debug: Print current scopes print(f"Current User Scopes: {current_user.scopes}") try: # 1. Announcements announcements = service.courses().announcements().list(courseId=course_id).execute().get('announcements', []) for a in announcements: a['type'] = 'announcement' stream_items.append(a) # 2. CourseWork (Assignments, Questions) coursework = service.courses().courseWork().list(courseId=course_id).execute().get('courseWork', []) for w in coursework: w['type'] = 'assignment' # or 'question' based on workType stream_items.append(w) # 3. CourseWorkMaterials materials = service.courses().courseWorkMaterials().list(courseId=course_id).execute().get('courseWorkMaterial', []) for m in materials: m['type'] = 'material' stream_items.append(m) except HttpError as e: if e.resp.status == 403: # Check for missing scopes required_scopes = set(app.config['GOOGLE_SCOPES']) current_scopes = set(current_user.scopes.split(',')) if current_user.scopes else set() missing_scopes = required_scopes - current_scopes if missing_scopes: print(f"Missing scopes: {missing_scopes}") logout_user() flash('New permissions are required. Please log in again to grant them.', 'info') return redirect(url_for('login')) flash(f'Error fetching stream: {str(e)}', 'warning') except Exception as e: flash(f'Error fetching stream: {str(e)}', 'warning') # Sort by creation time (newest first) # Note: Different items have different time fields (creationTime, updateTime) def get_sort_time(item): return item.get('creationTime') or item.get('updateTime') or '1970-01-01T00:00:00.000Z' stream_items.sort(key=get_sort_time, reverse=True) return render_template('course_stream.html', course=course, stream_items=stream_items, tags=tags, item_tags_map=item_tags_map)
[docs] @app.route('/sync_calendar') @login_required def sync_calendar(): """ Placeholder route for calendar synchronization. Returns: redirect: Redirects to index with a 'coming soon' message. """ # Placeholder for calendar sync logic # 1. Fetch assignments from Google Classroom # 2. Check if they exist in Google Calendar # 3. If not, add them flash('Calendar sync feature coming soon!', 'info') return redirect(url_for('index'))
[docs] @app.route('/login') def login(): """ Initiates the Google OAuth login flow. Returns: redirect: Redirects the user to Google's authorization URL. """ if current_user.is_authenticated: return redirect(url_for('index')) flow = Flow.from_client_secrets_file( app.config['GOOGLE_CLIENT_SECRETS_FILE'], scopes=app.config['GOOGLE_SCOPES'] ) flow.redirect_uri = url_for('callback', _external=True) authorization_url, state = flow.authorization_url( access_type='offline', include_granted_scopes='true', prompt='consent' # Force consent screen to ensure new scopes are granted ) session['state'] = state return redirect(authorization_url)
[docs] @app.route('/callback') def callback(): """ Handles the callback from Google OAuth. Exchanges the authorization code for tokens, fetches user info, creates/updates the user record in the database, and logs the user in. Returns: redirect: Redirects to the index page upon success. """ state = session['state'] flow = Flow.from_client_secrets_file( app.config['GOOGLE_CLIENT_SECRETS_FILE'], scopes=app.config['GOOGLE_SCOPES'], state=state ) flow.redirect_uri = url_for('callback', _external=True) authorization_response = request.url # Allow for scope changes/upgrades os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '1' flow.fetch_token(authorization_response=authorization_response) credentials = flow.credentials # Get user info service = build('oauth2', 'v2', credentials=credentials) user_info = service.userinfo().get().execute() google_id = user_info.get('id') email = user_info.get('email') name = user_info.get('name') picture = user_info.get('picture') # Check if user exists user = User.query.filter_by(google_id=google_id).first() if not user: user = User(google_id=google_id, email=email) # Update user info and tokens user.name = name user.picture = picture user.access_token = credentials.token user.refresh_token = credentials.refresh_token user.token_uri = credentials.token_uri user.client_id = credentials.client_id user.client_secret = credentials.client_secret user.scopes = ','.join(credentials.scopes) if credentials.scopes else '' db.session.add(user) db.session.commit() login_user(user, remember=True) flash('Successfully logged in!', 'success') return redirect(url_for('index'))
[docs] @app.route('/logout') def logout(): """ Logs the user out. Returns: redirect: Redirects to the index page. """ logout_user() flash('You have been logged out.', 'info') return redirect(url_for('index'))