diff --git a/cms/server/admin/handlers/__init__.py b/cms/server/admin/handlers/__init__.py index 1e589f78e4..f1fd8cb1db 100644 --- a/cms/server/admin/handlers/__init__.py +++ b/cms/server/admin/handlers/__init__.py @@ -197,10 +197,12 @@ from .student import \ TrainingProgramStudentsHandler, \ AddTrainingProgramStudentHandler, \ + BulkAddTrainingProgramStudentsHandler, \ RemoveTrainingProgramStudentHandler, \ StudentHandler, \ StudentTagsHandler, \ StudentTasksHandler, \ + StudentTaskSubmissionsHandler, \ AddStudentTaskHandler, \ RemoveStudentTaskHandler, \ BulkAssignTaskHandler @@ -373,12 +375,14 @@ # Training Program tabs (r"/training_program/([0-9]+)/students", TrainingProgramStudentsHandler), (r"/training_program/([0-9]+)/students/add", AddTrainingProgramStudentHandler), + (r"/training_program/([0-9]+)/students/bulk_add", BulkAddTrainingProgramStudentsHandler), (r"/training_program/([0-9]+)/student/([0-9]+)/remove", RemoveTrainingProgramStudentHandler), (r"/training_program/([0-9]+)/student/([0-9]+)/edit", StudentHandler), (r"/training_program/([0-9]+)/student/([0-9]+)/tags", StudentTagsHandler), (r"/training_program/([0-9]+)/student/([0-9]+)/tasks", StudentTasksHandler), (r"/training_program/([0-9]+)/student/([0-9]+)/tasks/add", AddStudentTaskHandler), (r"/training_program/([0-9]+)/student/([0-9]+)/task/([0-9]+)/remove", RemoveStudentTaskHandler), + (r"/training_program/([0-9]+)/student/([0-9]+)/task/([0-9]+)/submissions", StudentTaskSubmissionsHandler), (r"/training_program/([0-9]+)/bulk_assign_task", BulkAssignTaskHandler), (r"/training_program/([0-9]+)/tasks", TrainingProgramTasksHandler), (r"/training_program/([0-9]+)/tasks/add", AddTrainingProgramTaskHandler), diff --git a/cms/server/admin/handlers/contestranking.py b/cms/server/admin/handlers/contestranking.py index cba6e01036..94c97c1370 100644 --- a/cms/server/admin/handlers/contestranking.py +++ b/cms/server/admin/handlers/contestranking.py @@ -51,19 +51,28 @@ ) -class RankingHandler(BaseHandler): - """Shows the ranking for a contest. +class RankingCommonMixin: + """Mixin for handlers that need ranking logic (calculation and export).""" - """ - @require_permission(BaseHandler.AUTHENTICATED) - def get(self, contest_id, format="online"): + def _load_contest_data(self, contest_id: str) -> Contest: + """Load a contest with all necessary data for ranking. + + This method loads the contest with tasks, participations, and related + entities needed for ranking calculation and display. + + Args: + contest_id: The ID of the contest to load. + + Returns: + The fully loaded Contest object. + """ # This validates the contest id. self.safe_get_item(Contest, contest_id) # Load contest with tasks, participations, and statement views. # We use the score cache to get score and has_submissions. # partial is computed at render time via SQL aggregation for correctness. - self.contest: Contest = ( + contest: Contest = ( self.sql_session.query(Contest) .filter(Contest.id == contest_id) .options(joinedload("tasks")) @@ -74,7 +83,23 @@ def get(self, contest_id, format="online"): .options(joinedload("participations.statement_views")) .first() ) + return contest + + def _calculate_scores(self, contest, can_access_by_pt): + """Calculate scores for all participations in the contest. + + This method uses the efficient approach from RankingHandler: + 1. SQL aggregation for partial flags. + 2. Score cache for scores and submission existence. + 3. Two-phase commit to handle cache rebuilds safely. + contest: The contest object (with participations and tasks loaded). + can_access_by_pt: A dict (participation_id, task_id) -> bool indicating + if a participant can access a task. + + Returns: + show_teams (bool): Whether any participation has a team. + """ # SQL aggregation to compute t_partial for all participation/task pairs. # t_partial is True when there's an official submission that is not yet scored. # has_submissions is retrieved from the cache instead. @@ -94,9 +119,9 @@ def get(self, contest_id, format="online"): SubmissionResult.public_score.is_(None), SubmissionResult.public_score_details.is_(None), SubmissionResult.ranking_score_details.is_(None), - ) + ), ) - ).label('t_partial') + ).label("t_partial"), ) .join(Participation, Submission.participation_id == Participation.id) .join(Task, Submission.task_id == Task.id) @@ -104,10 +129,10 @@ def get(self, contest_id, format="online"): SubmissionResult, and_( SubmissionResult.submission_id == Submission.id, - SubmissionResult.dataset_id == Task.active_dataset_id - ) + SubmissionResult.dataset_id == Task.active_dataset_id, + ), ) - .filter(Participation.contest_id == contest_id) + .filter(Participation.contest_id == contest.id) .filter(Submission.official.is_(True)) .group_by(Submission.participation_id, Submission.task_id) ) @@ -115,24 +140,13 @@ def get(self, contest_id, format="online"): # Build lookup dict: (participation_id, task_id) -> t_partial partial_by_pt = {} for row in partial_flags_query.all(): - partial_by_pt[(row.participation_id, row.task_id)] = ( - row.t_partial or False - ) + partial_by_pt[(row.participation_id, row.task_id)] = row.t_partial or False statement_views_set = set() - for p in self.contest.participations: + for p in contest.participations: for sv in p.statement_views: statement_views_set.add((sv.participation_id, sv.task_id)) - # Build lookup for task accessibility based on visibility tags. - training_day = self.contest.training_day - can_access_by_pt = {} # (participation_id, task_id) -> bool - for p in self.contest.participations: - for task in self.contest.get_tasks(): - can_access_by_pt[(p.id, task.id)] = can_access_task( - self.sql_session, task, p, training_day - ) - # Preprocess participations: get data about teams, scores # Use the score cache to get score and has_submissions. # partial is computed via SQL aggregation above for correctness. @@ -144,13 +158,13 @@ def get(self, contest_id, format="online"): # which would clear any dynamically added attributes like task_statuses. show_teams = False participation_data = {} # p.id -> (task_statuses, total_score) - for p in self.contest.participations: + for p in contest.participations: show_teams = show_teams or p.team_id task_statuses = [] total_score = 0.0 partial = False - for task in self.contest.get_tasks(): + for task in contest.get_tasks(): # Get the cache entry with score and has_submissions cache_entry = get_cached_score_entry(self.sql_session, p, task) t_score = round(cache_entry.score, task.score_precision) @@ -171,7 +185,7 @@ def get(self, contest_id, format="online"): ) total_score += t_score partial = partial or t_partial - total_score = round(total_score, self.contest.score_precision) + total_score = round(total_score, contest.score_precision) participation_data[p.id] = (task_statuses, (total_score, partial)) # Commit to persist any cache rebuilds and release advisory locks. @@ -180,11 +194,128 @@ def get(self, contest_id, format="online"): # Now attach transient attributes after commit (so they aren't cleared # by SQLAlchemy's expire-on-commit behavior). - for p in self.contest.participations: + for p in contest.participations: p.task_statuses, p.total_score = participation_data[p.id] + return show_teams + + @staticmethod + def _status_indicator(status: TaskStatus) -> str: + star = "*" if status.partial else "" + if not status.can_access: + return "N/A" + if not status.has_submissions: + return "X" if not status.has_opened else "-" + if not status.has_opened: + return "!" + star + return star + + def _write_csv( + self, + contest, + participations, + tasks, + student_tags_by_participation, + show_teams, + include_partial=True, + task_archive_progress_by_participation=None, + ): + output = io.StringIO() + writer = csv.writer(output) + + # Build header row + row = ["Username", "User"] + if student_tags_by_participation: + row.append("Tags") + if task_archive_progress_by_participation: + row.append("Task Archive Progress") + if show_teams: + row.append("Team") + for task in tasks: + row.append(task.name) + if include_partial: + row.append("P") + + row.append("Global") + if include_partial: + row.append("P") + + writer.writerow(row) + + # Build task index lookup for task_statuses. + # We assume p.task_statuses follows the order of contest.get_tasks(). + all_tasks = list(contest.get_tasks()) + task_index = {task.id: i for i, task in enumerate(all_tasks)} + + for p in participations: + row = [p.user.username, "%s %s" % (p.user.first_name, p.user.last_name)] + if student_tags_by_participation: + tags = student_tags_by_participation.get(p.id, []) + row.append(", ".join(tags)) + if task_archive_progress_by_participation: + progress = task_archive_progress_by_participation.get(p.id, {}) + row.append( + "%.1f%% (%.1f/%.1f)" + % ( + progress.get("percentage", 0), + progress.get("total_score", 0), + progress.get("max_score", 0), + ) + ) + if show_teams: + row.append(p.team.name if p.team else "") + + # Calculate total score for exported tasks only + total_score = 0.0 + partial = False + for task in tasks: + idx = task_index.get(task.id) + if idx is not None and idx < len(p.task_statuses): + status = p.task_statuses[idx] + row.append(status.score) + if include_partial: + row.append(self._status_indicator(status)) + total_score += status.score + partial = partial or status.partial + else: + # Should not happen if data is consistent + row.append(0) + if include_partial: + row.append("") + + total_score = round(total_score, contest.score_precision) + row.append(total_score) + if include_partial: + row.append("*" if partial else "") + + writer.writerow(row) + + return output.getvalue() + + +class RankingHandler(RankingCommonMixin, BaseHandler): + """Shows the ranking for a contest.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, contest_id, format="online"): + self.contest = self._load_contest_data(contest_id) + + # Build lookup for task accessibility based on visibility tags. + training_day = self.contest.training_day + can_access_by_pt = {} # (participation_id, task_id) -> bool + for p in self.contest.participations: + for task in self.contest.get_tasks(): + can_access_by_pt[(p.id, task.id)] = can_access_task( + self.sql_session, task, p, training_day + ) + + show_teams = self._calculate_scores(self.contest, can_access_by_pt) + self.r_params = self.render_params() self.r_params["show_teams"] = show_teams + self.r_params["task_archive_progress_by_participation"] = ( + None # Only for training programs + ) # Check if this is a training day with main groups training_day = self.contest.training_day @@ -252,22 +383,19 @@ def get(self, contest_id, format="online"): # Sort participations by group-specific total score (sum of accessible tasks only) # Capture accessible_tasks in closure to avoid late binding issues def get_group_score(p, tasks=accessible_tasks): - return sum( - p.task_statuses[task_index[t.id]].score - for t in tasks - ) + return sum(p.task_statuses[task_index[t.id]].score for t in tasks) sorted_participations = sorted( - group_participations, - key=get_group_score, - reverse=True + group_participations, key=get_group_score, reverse=True ) - main_groups_data.append({ - "name": mg, - "participations": sorted_participations, - "tasks": accessible_tasks, - }) + main_groups_data.append( + { + "name": mg, + "participations": sorted_participations, + "tasks": accessible_tasks, + } + ) # Get all student tags for display self.r_params["all_student_tags"] = get_all_student_tags(training_program) @@ -297,8 +425,7 @@ def get_group_score(p, tasks=accessible_tasks): else: filename = f"{date_str}_{contest_name}_ranking.txt" self.set_header("Content-Type", "text/plain") - self.set_header("Content-Disposition", - f"attachment; filename=\"{filename}\"") + self.set_header("Content-Disposition", f'attachment; filename="{filename}"') self.render("ranking.txt", **self.r_params) elif format == "csv": if export_group_data: @@ -307,13 +434,7 @@ def get_group_score(p, tasks=accessible_tasks): else: filename = f"{date_str}_{contest_name}_ranking.csv" self.set_header("Content-Type", "text/csv") - self.set_header("Content-Disposition", - f"attachment; filename=\"{filename}\"") - - output = io.StringIO() - writer = csv.writer(output) - - include_partial = True + self.set_header("Content-Disposition", f'attachment; filename="{filename}"') contest: Contest = self.r_params["contest"] @@ -325,74 +446,25 @@ def get_group_score(p, tasks=accessible_tasks): export_participations = sorted( [p for p in contest.participations if not p.hidden], key=lambda p: p.total_score, - reverse=True + reverse=True, ) export_tasks = list(contest.get_tasks()) - # Build header row - row = ["Username", "User"] - if student_tags_by_participation: - row.append("Tags") - if show_teams: - row.append("Team") - for task in export_tasks: - row.append(task.name) - if include_partial: - row.append("P") - - row.append("Global") - if include_partial: - row.append("P") - - writer.writerow(row) - - # Build task index lookup for task_statuses - all_tasks = list(contest.get_tasks()) - task_index = {task.id: i for i, task in enumerate(all_tasks)} - - for p in export_participations: - row = [p.user.username, - "%s %s" % (p.user.first_name, p.user.last_name)] - if student_tags_by_participation: - tags = student_tags_by_participation.get(p.id, []) - row.append(", ".join(tags)) - if show_teams: - row.append(p.team.name if p.team else "") - - # Calculate total score for exported tasks only - total_score = 0.0 - partial = False - for task in export_tasks: - idx = task_index[task.id] - status = p.task_statuses[idx] - row.append(status.score) - if include_partial: - row.append(self._status_indicator(status)) - total_score += status.score - partial = partial or status.partial - - total_score = round(total_score, contest.score_precision) - row.append(total_score) - if include_partial: - row.append("*" if partial else "") - - writer.writerow(row) - - self.finish(output.getvalue()) + csv_content = self._write_csv( + contest, + export_participations, + export_tasks, + student_tags_by_participation, + show_teams, + include_partial=True, + task_archive_progress_by_participation=self.r_params.get( + "task_archive_progress_by_participation" + ), + ) + self.finish(csv_content) else: self.render("ranking.html", **self.r_params) - @staticmethod - def _status_indicator(status: TaskStatus) -> str: - star = "*" if status.partial else "" - if not status.can_access: - return "N/A" - if not status.has_submissions: - return "X" if not status.has_opened else "-" - if not status.has_opened: - return "!" + star - return star - class ScoreHistoryHandler(BaseHandler): """Returns the score history for a contest as JSON. diff --git a/cms/server/admin/handlers/contestuser.py b/cms/server/admin/handlers/contestuser.py index 96373075d8..cfec1ec7fd 100644 --- a/cms/server/admin/handlers/contestuser.py +++ b/cms/server/admin/handlers/contestuser.py @@ -44,6 +44,7 @@ from cms.db import Contest, Message, Participation, Submission, User, Team, TrainingDay from cms.db.training_day import get_managing_participation +from cms.server.util import parse_usernames_from_file from cmscommon.crypto import validate_password_strength from cmscommon.datetime import make_datetime from .base import BaseHandler, require_permission @@ -193,7 +194,7 @@ def post(self, contest_id): file_data = self.request.files["users_file"][0] file_content = file_data["body"].decode("utf-8") - usernames = file_content.split() + usernames = parse_usernames_from_file(file_content) if not usernames: raise ValueError("File is empty or contains no usernames") @@ -202,10 +203,6 @@ def post(self, contest_id): users_added = 0 for username in usernames: - username = username.strip() - if not username: - continue - user = self.sql_session.query(User).filter( User.username == username).first() diff --git a/cms/server/admin/handlers/student.py b/cms/server/admin/handlers/student.py index 24a331d1a2..b930486b23 100644 --- a/cms/server/admin/handlers/student.py +++ b/cms/server/admin/handlers/student.py @@ -23,6 +23,8 @@ import tornado.web +from sqlalchemy import func + from cms.db import ( TrainingProgram, Participation, @@ -38,7 +40,9 @@ from cms.server.util import ( get_all_student_tags, calculate_task_archive_progress, + get_student_archive_scores, parse_tags, + parse_usernames_from_file, ) from cmscommon.datetime import make_datetime @@ -71,10 +75,17 @@ def get(self, training_program_id: str): student_progress = {} for student in training_program.students: student_progress[student.id] = calculate_task_archive_progress( - student, student.participation, managing_contest + student, student.participation, managing_contest, self.sql_session ) + # Commit to release any advisory locks taken by get_cached_score_entry + self.sql_session.commit() self.r_params["student_progress"] = student_progress + self.r_params["bulk_add_results"] = None + + # For bulk assign task modal + self.r_params["all_tasks"] = managing_contest.get_tasks() + self.r_params["all_student_tags"] = get_all_student_tags(training_program) self.render("training_program_students.html", **self.r_params) @@ -85,24 +96,25 @@ def post(self, training_program_id: str): self.safe_get_item(TrainingProgram, training_program_id) try: - user_id = self.get_argument("user_id") operation = self.get_argument("operation") - assert operation in ( - self.REMOVE_FROM_PROGRAM, - ), "Please select a valid operation" + # Support both old format (radio button + "Remove from training program") + # and new format (button with value "remove_") + if operation == self.REMOVE_FROM_PROGRAM: + user_id = self.get_argument("user_id") + elif operation.startswith("remove_"): + user_id = operation.replace("remove_", "") + else: + raise ValueError("Please select a valid operation") except Exception as error: self.service.add_notification( make_datetime(), "Invalid field(s)", repr(error)) self.redirect(fallback_page) return - if operation == self.REMOVE_FROM_PROGRAM: - asking_page = \ - self.url("training_program", training_program_id, "student", user_id, "remove") - self.redirect(asking_page) - return - - self.redirect(fallback_page) + # Redirect to confirmation page + asking_page = \ + self.url("training_program", training_program_id, "student", user_id, "remove") + self.redirect(asking_page) class AddTrainingProgramStudentHandler(BaseHandler): @@ -160,6 +172,129 @@ def post(self, training_program_id: str): self.redirect(fallback_page) +class BulkAddTrainingProgramStudentsHandler(BaseHandler): + """Bulk add students to a training program from a file.""" + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, training_program_id: str): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + + try: + if "students_file" not in self.request.files: + raise ValueError("No file uploaded") + + file_data = self.request.files["students_file"][0] + file_content = file_data["body"].decode("utf-8") + + usernames = parse_usernames_from_file(file_content) + + if not usernames: + raise ValueError("File is empty or contains no usernames") + + results = [] + students_added = 0 + + for username in usernames: + user = self.sql_session.query(User).filter( + User.username == username).first() + + if user is None: + results.append({ + "username": username, + "status": "not_found", + "message": "Username does not exist in the system" + }) + else: + existing_participation = ( + self.sql_session.query(Participation) + .filter(Participation.contest == managing_contest) + .filter(Participation.user == user) + .first() + ) + + if existing_participation is not None: + results.append({ + "username": username, + "status": "already_exists", + "message": "User is already a student in this program" + }) + else: + participation = Participation( + contest=managing_contest, + user=user, + starting_time=make_datetime() + ) + self.sql_session.add(participation) + self.sql_session.flush() + + student = Student( + training_program=training_program, + participation=participation, + student_tags=[] + ) + self.sql_session.add(student) + + for training_day in training_program.training_days: + if training_day.contest is None: + continue + td_participation = Participation( + contest=training_day.contest, + user=user + ) + self.sql_session.add(td_participation) + + results.append({ + "username": username, + "status": "success", + "message": "Successfully added to training program" + }) + students_added += 1 + + if self.try_commit(): + if students_added > 0: + self.service.proxy_service.reinitialize() + else: + # Commit failed - redirect to avoid showing misleading results + self.redirect( + self.url("training_program", training_program_id, "students") + ) + return + + self.render_params_for_training_program(training_program) + + assigned_user_ids_q = self.sql_session.query(Participation.user_id).filter( + Participation.contest == managing_contest + ) + + self.r_params["unassigned_users"] = ( + self.sql_session.query(User) + .filter(~User.id.in_(assigned_user_ids_q)) + .filter(~User.username.like(r"\_\_%", escape="\\")) + .all() + ) + + student_progress = {} + for student in training_program.students: + student_progress[student.id] = calculate_task_archive_progress( + student, student.participation, managing_contest, self.sql_session + ) + # Commit to release any advisory locks taken by get_cached_score_entry + self.sql_session.commit() + + self.r_params["student_progress"] = student_progress + self.r_params["bulk_add_results"] = results + self.r_params["students_added"] = students_added + self.r_params["all_tasks"] = managing_contest.get_tasks() + self.r_params["all_student_tags"] = get_all_student_tags(training_program) + self.render("training_program_students.html", **self.r_params) + + except Exception as error: + self.service.add_notification( + make_datetime(), "Error processing file", repr(error)) + self.redirect(self.url("training_program", training_program_id, "students")) + + class RemoveTrainingProgramStudentHandler(BaseHandler): """Confirm and remove a student from a training program.""" @@ -179,15 +314,17 @@ def get(self, training_program_id: str, user_id: str): if participation is None: raise tornado.web.HTTPError(404) - submission_query = self.sql_session.query(Submission)\ - .filter(Submission.participation == participation) - self.render_params_for_remove_confirmation(submission_query) - - # Use the helper to set up training program params + # Use the helper to set up training program params first + # (this initializes r_params, so it must come before render_params_for_remove_confirmation) self.render_params_for_training_program(training_program) self.r_params["unanswered"] = 0 # Override for deletion confirmation page self.r_params["user"] = user + # Now add submission count (this adds to existing r_params) + submission_query = self.sql_session.query(Submission)\ + .filter(Submission.participation == participation) + self.render_params_for_remove_confirmation(submission_query) + # Count submissions and participations from training days training_day_contest_ids = [td.contest_id for td in training_program.training_days] training_day_contest_ids = [ @@ -299,10 +436,12 @@ def get(self, training_program_id: str, user_id: str): Submission.participation == participation ) page = int(self.get_query_argument("page", "0")) - self.render_params_for_submissions(submission_query, page) # render_params_for_training_program sets training_program, contest, unanswered self.render_params_for_training_program(training_program) + + self.render_params_for_submissions(submission_query, page) + self.r_params["participation"] = participation self.r_params["student"] = student self.r_params["selected_user"] = participation.user @@ -489,10 +628,13 @@ def get(self, training_program_id: str, user_id: str): assigned_task_ids = {st.task_id for st in student.student_tasks} available_tasks = [t for t in all_tasks if t.id not in assigned_task_ids] - # Build home scores from participation task_scores cache - home_scores = {} - for pts in participation.task_scores: - home_scores[pts.task_id] = pts.score + # Build home scores using get_student_archive_scores for fresh cache values + # This avoids stale entries in participation.task_scores + home_scores = get_student_archive_scores( + self.sql_session, student, participation, managing_contest + ) + # Commit to release advisory locks from cache rebuilds + self.sql_session.commit() # Build training scores from archived student rankings (batch query) training_scores = {} @@ -522,6 +664,21 @@ def get(self, training_program_id: str, user_id: str): if task_id_str in archived_ranking.task_scores: training_scores[st.task_id] = archived_ranking.task_scores[task_id_str] + # Get submission counts for each task (batch query for efficiency) + submission_counts = {} + if assigned_task_ids: + counts = ( + self.sql_session.query( + Submission.task_id, + func.count(Submission.id) + ) + .filter(Submission.participation_id == participation.id) + .filter(Submission.task_id.in_(assigned_task_ids)) + .group_by(Submission.task_id) + .all() + ) + submission_counts = {task_id: count for task_id, count in counts} + self.render_params_for_training_program(training_program) self.r_params["participation"] = participation self.r_params["student"] = student @@ -532,9 +689,73 @@ def get(self, training_program_id: str, user_id: str): self.r_params["available_tasks"] = available_tasks self.r_params["home_scores"] = home_scores self.r_params["training_scores"] = training_scores + self.r_params["submission_counts"] = submission_counts self.render("student_tasks.html", **self.r_params) +class StudentTaskSubmissionsHandler(BaseHandler): + """View submissions for a specific task in a student's archive.""" + + @require_permission(BaseHandler.AUTHENTICATED) + def get(self, training_program_id: str, user_id: str, task_id: str): + training_program = self.safe_get_item(TrainingProgram, training_program_id) + managing_contest = training_program.managing_contest + task = self.safe_get_item(Task, task_id) + + # Validate task belongs to the training program + if task.contest_id != managing_contest.id: + raise tornado.web.HTTPError(404) + + participation: Participation | None = ( + self.sql_session.query(Participation) + .filter(Participation.contest_id == managing_contest.id) + .filter(Participation.user_id == user_id) + .first() + ) + + if participation is None: + raise tornado.web.HTTPError(404) + + student: Student | None = ( + self.sql_session.query(Student) + .filter(Student.participation == participation) + .filter(Student.training_program == training_program) + .first() + ) + + if student is None: + raise tornado.web.HTTPError(404) + + # Verify student is assigned this specific task + student_task = ( + self.sql_session.query(StudentTask) + .filter(StudentTask.student == student) + .filter(StudentTask.task == task) + .first() + ) + + if student_task is None: + raise tornado.web.HTTPError(404) + + # Filter submissions by task + self.contest = managing_contest + submission_query = ( + self.sql_session.query(Submission) + .filter(Submission.participation == participation) + .filter(Submission.task_id == task.id) + ) + page = int(self.get_query_argument("page", "0")) + + self.render_params_for_training_program(training_program) + self.render_params_for_submissions(submission_query, page) + + self.r_params["participation"] = participation + self.r_params["student"] = student + self.r_params["selected_user"] = participation.user + self.r_params["task"] = task + self.render("student_task_submissions.html", **self.r_params) + + class AddStudentTaskHandler(BaseHandler): """Add a task to a student's task archive.""" @@ -670,28 +891,17 @@ def post(self, training_program_id: str, user_id: str, task_id: str): class BulkAssignTaskHandler(BaseHandler): - """Bulk assign a task to all students with a given tag.""" + """Bulk assign a task to all students with a given tag. - @require_permission(BaseHandler.PERMISSION_ALL) - def get(self, training_program_id: str): - training_program = self.safe_get_item(TrainingProgram, training_program_id) - managing_contest = training_program.managing_contest - - # Get all tasks in the training program - all_tasks = managing_contest.get_tasks() - - # Get all unique student tags - all_student_tags = get_all_student_tags(training_program) - - self.render_params_for_training_program(training_program) - self.r_params["all_tasks"] = all_tasks - self.r_params["all_student_tags"] = all_student_tags - self.render("bulk_assign_task.html", **self.r_params) + Note: The GET method was removed as the bulk assign task functionality + is now handled via a modal dialog on the students page. + """ @require_permission(BaseHandler.PERMISSION_ALL) def post(self, training_program_id: str): + # Redirect to students page (modal is now on that page) fallback_page = self.url( - "training_program", training_program_id, "bulk_assign_task" + "training_program", training_program_id, "students" ) training_program = self.safe_get_item(TrainingProgram, training_program_id) diff --git a/cms/server/admin/handlers/trainingprogram.py b/cms/server/admin/handlers/trainingprogram.py index 10291a8faa..37e2a34e55 100644 --- a/cms/server/admin/handlers/trainingprogram.py +++ b/cms/server/admin/handlers/trainingprogram.py @@ -35,23 +35,26 @@ from cms.db import ( Contest, - DelayRequest, TrainingProgram, Participation, Submission, - User, Task, Question, Announcement, Student, + StudentTask, ) from cms.server.util import ( get_all_student_tags, parse_tags, + calculate_task_archive_progress, ) from cmscommon.datetime import make_datetime from .base import BaseHandler, SimpleHandler, require_permission +from .contestranking import RankingCommonMixin + + class TrainingProgramListHandler(SimpleHandler("training_programs.html")): """List all training programs. @@ -615,64 +618,43 @@ def delete(self, training_program_id: str, task_id: str): self.write("../../tasks") -class TrainingProgramRankingHandler(BaseHandler): +class TrainingProgramRankingHandler(RankingCommonMixin, BaseHandler): """Show ranking for a training program.""" @require_permission(BaseHandler.AUTHENTICATED) def get(self, training_program_id: str, format: str = "online"): - import csv - import io - from sqlalchemy.orm import joinedload - from cms.grading.scoring import task_score - from .contestranking import TaskStatus - training_program = self.safe_get_item(TrainingProgram, training_program_id) managing_contest = training_program.managing_contest - self.contest = ( - self.sql_session.query(Contest) - .filter(Contest.id == managing_contest.id) - .options(joinedload("participations")) - .options(joinedload("participations.submissions")) - .options(joinedload("participations.submissions.token")) - .options(joinedload("participations.submissions.results")) - .options(joinedload("participations.statement_views")) - .first() - ) + self.contest = self._load_contest_data(managing_contest.id) - statement_views_set = set() + # Build a dict of (participation_id, task_id) -> bool for tasks that students can access + # A student can access a task if they have a StudentTask record for it + # Default is False since we're whitelisting access via StudentTask + can_access_by_pt = {} for p in self.contest.participations: - for sv in p.statement_views: - statement_views_set.add((sv.participation_id, sv.task_id)) + for task in self.contest.get_tasks(): + can_access_by_pt[(p.id, task.id)] = False - show_teams = False - for p in self.contest.participations: - show_teams = show_teams or p.team_id + participation_ids = [p.id for p in self.contest.participations] + if participation_ids: + rows = ( + self.sql_session.query(Student.participation_id, StudentTask.task_id) + .join(StudentTask, Student.id == StudentTask.student_id) + .filter(Student.training_program_id == training_program.id) + .filter(Student.participation_id.in_(participation_ids)) + .all() + ) + for participation_id, task_id in rows: + can_access_by_pt[(participation_id, task_id)] = True - p.task_statuses = [] - total_score = 0.0 - partial = False - for task in self.contest.get_tasks(): - t_score, t_partial = task_score(p, task, rounded=True) - has_submissions = any(s.task_id == task.id and s.official - for s in p.submissions) - has_opened = (p.id, task.id) in statement_views_set - p.task_statuses.append( - TaskStatus( - score=t_score, - partial=t_partial, - has_submissions=has_submissions, - has_opened=has_opened, - can_access=True, - ) - ) - total_score += t_score - partial = partial or t_partial + show_teams = self._calculate_scores(self.contest, can_access_by_pt) - # Ensure task_statuses align with template header order - assert len(self.contest.get_tasks()) == len(p.task_statuses) - total_score = round(total_score, self.contest.score_precision) - p.total_score = (total_score, partial) + # Store participation data before commit (SQLAlchemy expires attributes on commit) + participation_data = {} + for p in self.contest.participations: + if hasattr(p, "task_statuses"): + participation_data[p.id] = (p.task_statuses, p.total_score) # Build student tags lookup for each participation (batch query) student_tags_by_participation = {p.id: [] for p in self.contest.participations} @@ -690,90 +672,73 @@ def get(self, training_program_id: str, format: str = "online"): for participation_id, tags in rows: student_tags_by_participation[participation_id] = tags or [] + # Calculate task archive progress for this training program + task_archive_progress_by_participation = {} + students_query = ( + self.sql_session.query(Student) + .filter(Student.training_program_id == training_program.id) + .all() + ) + student_by_participation_id = {s.participation_id: s for s in students_query} + + for p in self.contest.participations: + student = student_by_participation_id.get(p.id) + if student: + progress = calculate_task_archive_progress( + student, p, self.contest, self.sql_session + ) + task_archive_progress_by_participation[p.id] = progress + + # Commit to release any advisory locks taken during score calculation + self.sql_session.commit() + + # Re-assign task_statuses after commit (SQLAlchemy expired them) + if "participation_data" in locals(): + for p in self.contest.participations: + if p.id in participation_data: + p.task_statuses, p.total_score = participation_data[p.id] + self.render_params_for_training_program(training_program) self.r_params["show_teams"] = show_teams self.r_params["student_tags_by_participation"] = student_tags_by_participation self.r_params["main_groups_data"] = None # Not used for training program ranking + self.r_params["task_archive_progress_by_participation"] = ( + task_archive_progress_by_participation + ) if format == "txt": self.set_header("Content-Type", "text/plain") - self.set_header("Content-Disposition", - "attachment; filename=\"ranking.txt\"") + filename = f"{training_program.name}_home_ranking.txt".replace( + " ", "_" + ).replace("/", "_") + self.set_header("Content-Disposition", f'attachment; filename="{filename}"') self.render("ranking.txt", **self.r_params) elif format == "csv": self.set_header("Content-Type", "text/csv") - self.set_header("Content-Disposition", - "attachment; filename=\"ranking.csv\"") - - output = io.StringIO() - writer = csv.writer(output) - - include_partial = True - - row = ["Username", "User"] - if student_tags_by_participation: - row.append("Tags") - if show_teams: - row.append("Team") - for task in self.contest.tasks: - row.append(task.name) - if include_partial: - row.append("P") - - row.append("Global") - if include_partial: - row.append("P") - - writer.writerow(row) - - for p in sorted(self.contest.participations, - key=lambda p: p.total_score, reverse=True): - if p.hidden: - continue - - row = [p.user.username, - "%s %s" % (p.user.first_name, p.user.last_name)] - if student_tags_by_participation: - tags = student_tags_by_participation.get(p.id, []) - row.append(", ".join(tags)) - if show_teams: - row.append(p.team.name if p.team else "") - assert len(self.contest.tasks) == len(p.task_statuses) - for status in p.task_statuses: - row.append(status.score) - if include_partial: - row.append(self._status_indicator(status)) - - total_score, partial = p.total_score - row.append(total_score) - if include_partial: - row.append("*" if partial else "") - - writer.writerow(row) - - self.finish(output.getvalue()) + filename = f"{training_program.name}_home_ranking.csv".replace( + " ", "_" + ).replace("/", "_") + self.set_header("Content-Disposition", f'attachment; filename="{filename}"') + + export_participations = sorted( + [p for p in self.contest.participations if not p.hidden], + key=lambda p: p.total_score, + reverse=True, + ) + + csv_content = self._write_csv( + self.contest, + export_participations, + list(self.contest.get_tasks()), + student_tags_by_participation, + show_teams, + include_partial=True, + task_archive_progress_by_participation=task_archive_progress_by_participation, + ) + self.finish(csv_content) else: self.render("ranking.html", **self.r_params) - @staticmethod - def _status_indicator(status) -> str: - """Return a status indicator string for CSV export. - - status: a TaskStatus namedtuple with score, partial, has_submissions, - has_opened, can_access fields. - - return: a string indicator for the status. - - """ - star = "*" if status.partial else "" - if not status.can_access: - return "N/A" - if not status.has_submissions: - return "X" if not status.has_opened else "-" - if not status.has_opened: - return "!" + star - return star - class TrainingProgramSubmissionsHandler(BaseHandler): """Show submissions for a training program.""" diff --git a/cms/server/admin/static/aws_style.css b/cms/server/admin/static/aws_style.css index 5591e06eb8..de179249a6 100644 --- a/cms/server/admin/static/aws_style.css +++ b/cms/server/admin/static/aws_style.css @@ -77,10 +77,6 @@ em { color: #F31313; } -#global { - margin: 0; -} - a, a:visited { color: #426DC9; } @@ -99,10 +95,6 @@ hr { clear: both; } -.displayed { - display: block; -} - input, textarea { border: 1px solid #53637D; border-radius: 4px; @@ -117,12 +109,6 @@ input:focus, textarea:focus { border: 1px solid #FFD69C; } -ul.normal_list { - list-style: disc; - list-style-position: inside; - padding-left: 0.5em; -} - .inline { display: inline; } @@ -1390,545 +1376,12 @@ table.diff-open th.diff-only, table.diff-open td.diff-only { word-wrap: break-word; } -/* Student tags display styling */ -/* Note: Tagify replaces the input with its own .tagify wrapper, - so we target .tagify for the actual styling */ -.student-tags-display { - border: none; - background: transparent; - width: 100%; -} - -.student-tags-display + .tagify, -.tagify.student-tags-display { - border: none; - background: transparent; - width: 100%; -} - -.no-tags-indicator { - color: #64748B; /* Improved contrast for WCAG AA compliance */ - font-style: italic; -} - -/* --- Training Program Views (Attendance & Ranking) --- */ - -/* Layout & Container */ -.tp-page-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; - flex-wrap: wrap; - gap: 15px; -} - .core_title h1 { margin: 0; font-size: 1.75rem; color: #111827; } -/* Filter Bar */ -.tp-filter-card { - background-color: #f9fafb; - border: 1px solid #e5e7eb; - border-radius: 8px; - padding: 12px 16px; - margin-bottom: 24px; - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.tp-filter-group { - display: flex; - align-items: center; - gap: 8px; -} - -.tp-filter-label { - font-weight: 600; - font-size: 0.85rem; - color: #4b5563; -} - -.tp-filter-input { - padding: 6px 10px; - border: 1px solid #cbd5e1; - border-radius: 4px; - font-size: 0.9rem; - color: #334155; - transition: border-color 0.2s, box-shadow 0.2s; -} - -.tp-filter-input:focus { - border-color: #0F766E; - box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.1); - outline: none; -} - - -.tp-btn-primary { - background-color: #0F766E; - color: white; - border: none; - padding: 6px 16px; - border-radius: 6px; - cursor: pointer; - font-weight: 500; - font-size: 0.9rem; - font-family: inherit; - transition: all 0.2s; - text-decoration: none; - display: inline-block; - line-height: 1.5; - box-sizing: border-box; - vertical-align: middle; -} - -.tp-btn-primary:hover { - background-color: #0d655e; - color: white; -} - -.tp-btn-text { - color: #6b7280; - text-decoration: underline; - font-size: 0.85rem; - margin-left: auto; -} - -.tp-btn-secondary { - background-color: white; - color: #0F766E; - border: 1px solid #cbd5e1; - padding: 6px 16px; - border-radius: 6px; - cursor: pointer; - font-weight: 500; - font-size: 0.9rem; - font-family: inherit; - transition: all 0.2s; - text-decoration: none; - display: inline-block; - line-height: 1.5; - box-sizing: border-box; - vertical-align: middle; -} - -/* Data Table Wrapper */ -.tp-table-container { - overflow-x: auto; - max-height: 75vh; - border: 1px solid #e2e8f0; - border-radius: 8px; - background: white; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - position: relative; - /* Ensure the container width is handled correctly */ - width: 100%; -} - -/* Common Table Styles */ -.attendance-table, -.ranking-table { - width: auto; - border-collapse: separate; /* Required for sticky headers */ - border-spacing: 0; - font-size: 0.9rem; - white-space: nowrap; -} - -/* Force border-box for consistency */ -.ranking-table *, .attendance-table * { - box-sizing: border-box; -} - -.attendance-table th, -.attendance-table td, -.ranking-table th, -.ranking-table td { - padding: 0; /* Reset padding, inner divs handle it */ - border-bottom: 1px solid #e2e8f0; - border-right: 1px solid #e2e8f0; - background-clip: padding-box; - vertical-align: middle; - height: 1px; -} - -/* --- Sticky Headers & Columns --- */ - -/* 1. Header Rows */ -.attendance-table thead th, -.ranking-table thead th { - position: sticky; - top: 0; - background-color: #f8fafc; - z-index: 10; - color: #475569; - font-weight: 600; - text-align: left; - box-shadow: 0 1px 0 #cbd5e1; /* Visual bottom border */ - padding: 10px 12px; /* Headers get direct padding */ -} - -.ranking-table thead th { - text-align: center; -} - -.ranking-table thead th.student-header { - text-align: left; - vertical-align: middle; -} - -/* 2. First Column (Students) */ -.attendance-table tbody th, -.ranking-table tbody th { - position: sticky; - left: 0; - background-color: white; - z-index: 8; - text-align: left; - min-width: 250px; - max-width: 300px; - box-shadow: 4px 0 8px -2px rgba(0, 0, 0, 0.1); - clip-path: inset(0px -10px 0px 0px); - border-right: 1px solid #e2e8f0; - border-bottom: 1px solid #f1f5f9; - overflow: hidden; - text-overflow: ellipsis; - color: #1e293b; - padding: 0; -} - -/* 3. Top-Left Corner */ -.attendance-table thead tr:first-child th:first-child, -.ranking-table thead tr:first-child th:first-child { - left: 0; - z-index: 20; - background-color: #f8fafc; - box-shadow: 1px 1px 0 #cbd5e1; -} - -/* --- Attendance Table Specifics --- */ -.date-header-main { font-size: 0.95rem; color: #111827; } -.date-header-sub { font-size: 0.75rem; color: #6b7280; font-weight: normal; margin-top: 2px; } - -/* Wrapper for cell content */ -.cell-wrapper { - display: flex; - flex-direction: column; - gap: 6px; - min-width: 140px; - padding: 10px 12px; -} - -/* Modern Badges */ -.status-badge { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 9999px; - font-size: 0.75rem; - font-weight: 600; - width: fit-content; -} - -.status-on-time { background-color: #d1fae5; color: #065f46; } -.status-delayed { background-color: #fef3c7; color: #92400e; } -.status-missed { background-color: #fee2e2; color: #991b1b; } -.status-unknown { background-color: #f3f4f6; color: #4b5563; } - -.location-row { - display: flex; - align-items: center; - gap: 4px; - font-size: 0.75rem; - color: #4b5563; -} - -.location-icon { font-size: 0.85rem; } - -.reason-text { - font-size: 0.75rem; - color: #6b7280; - background: #f9fafb; - border-left: 2px solid #d1d5db; - padding: 2px 6px; - margin-top: 2px; - white-space: normal; - max-width: 200px; -} - -.attendance-table .empty-cell { color: #d1d5db; font-size: 1.5rem; line-height: 0.5; padding: 10px; text-align: center;} - - -/* --- Combined Ranking Specifics --- */ -.training-day-header { - background-color: #f1f5f9 !important; - text-align: center; - border-bottom: 1px solid #cbd5e1 !important; - pointer-events: none; -} - -.global-total-header { - background-color: #f1f5f9 !important; - color: #0f172a; - font-weight: 800; - border-left: none !important; - border-right: 1px solid #e2e8f0 !important; -} - -.ranking-table td.global-cell { - background-color: #f3f4f6 !important; - font-weight: 700; - border-left: none !important; - border-right: 1px solid #e2e8f0 !important; -} - -.global-total-header::after, -.ranking-table td.global-cell::after { - content: none !important; - display: none !important; -} - -.training-day-header-main { - font-size: 0.95rem; - color: #334155; - font-weight: 700; -} - -.training-day-header-sub { - font-size: 0.75rem; - color: #64748b; - font-weight: normal; - margin-top: 2px; -} - -.task-header { - background-color: #ffffff !important; - font-size: 0.8rem; - color: #475569; - vertical-align: bottom; - border-left: 1px solid #e2e8f0 !important; - border-top: 3px solid #e2e8f0; -} - -/* Total Column specific styling */ -.total-col { - border-left: 2px solid #e2e8f0 !important; - background-color: #f1f5f9; -} - -.ranking-table thead th.total-col { - background-color: #f1f5f9 !important; - color: #0f172a; - font-weight: 800; -} - -.ranking-table tbody td.total-col { - background-color: #f1f5f9 !important; - font-weight: 700; - color: #0f172a; -} - -.ranking-table th.training-day-header, -.ranking-table th.total-col, -.ranking-table td.total-col { - border-right: none !important; -} - -.ranking-table th.training-day-header, -.ranking-table th.total-col { - position: sticky; - z-index: 11; -} - -.ranking-table td.total-col { - position: relative; - z-index: 5; -} - -.ranking-table th.training-day-header::after, -.ranking-table th.total-col::after, -.ranking-table td.total-col::after { - content: ""; - position: absolute; - top: 0; - bottom: 0; - right: 0; - width: 4px; - background-color: #cbd5e1; - z-index: 10; -} - -/* Content wrapper for Ranking cells */ -.cell-content { - display: flex; - align-items: center; - justify-content: center; - padding: 12px 8px; - font-size: 1.25rem; /* Much smaller than 1.5em */ - font-weight: 600; - border-radius: 6px; /* Soften the blockiness */ - margin: 4px; /* Give the cell breathing room inside the grid */ - height: calc(100% - 8px); - width: calc(100% - 8px); - color: #1e293b; /* Dark Slate, not black */ - background-image: linear-gradient(rgba(255,255,255,0.3), rgba(255,255,255,0.3)); -} - -/* Student name wrapper */ -.student-name-wrapper { - display: flex; - flex-direction: column; - justify-content: center; - padding: 10px 16px; - width: 100%; - height: 100%; -} - -.student-name-link { - color: #0f172a; - text-decoration: none; - font-weight: 600; -} -.student-name-link:hover { - color: #0F766E; - text-decoration: underline; -} -.student-username { - display: block; - font-size: 0.75rem; - color: #64748b; - font-weight: normal; -} - -.ranking-table .empty-cell-content { - color: #cbd5e1; - text-align: center; - font-weight: normal; -} - -.inaccessible-cell { - background-color: #f3f4f6; - background-image: radial-gradient(#e5e7eb 1px, transparent 1px); - background-size: 10px 10px; - color: #9ca3af; -} - -/* Attendance badges for combined ranking */ -.attendance-badge { - margin-left: 4px; - font-size: 0.75em; - vertical-align: middle; -} - -.home-badge { - opacity: 0.7; -} - -.missed-badge { - opacity: 0.8; -} - -.history-link { - font-size: 0.75rem; - color: #6b7280; - text-decoration: none; - margin-left: 4px; -} - -.history-link:hover { - color: #374151; - text-decoration: underline; -} - -/* Hover effects */ -.ranking-table tbody tr:hover td { - background-color: #e2e8f0; -} -.ranking-table tbody tr:hover th { - background-color: #e2e8f0; -} - -.ranking-table tbody tr:nth-child(even) { - background-color: #f8fafc; /* Very light gray */ -} - - -.badge { - display: inline-flex; - align-items: center; - padding: 3px 10px; - font-size: 0.75em; - font-weight: 600; - line-height: 1.2; - text-align: center; - white-space: nowrap; - vertical-align: baseline; - border-radius: 9999px; - margin-right: 4px; - margin-bottom: 2px; - border: 1px solid transparent; -} - -.badge-pill { - border-radius: 9999px; - padding-left: 8px; - padding-right: 8px; -} - -.badge-teal { - background-color: #f0fdfa; - color: #0f766e; - border-color: #ccfbf1; -} -.badge-slate { - background-color: #f8fafc; - color: #475569; - border-color: #e2e8f0; -} - -.ranking-table thead th { - position: sticky; -} - -.ranking-table thead tr:first-child th { - height: auto; - padding: 12px 8px; - background-color: #fff; - border-bottom: 2px solid #e2e8f0; -} - -.ranking-table thead tr:nth-child(2) th { - top: var(--ranking-header-top); - z-index: 15; - box-shadow: 0 2px 4px -2px rgba(0,0,0,0.1); - background-color: #fff; -} - -.ranking-table thead tr:first-child th[rowspan="2"] { - top: 0; - height: auto; - z-index: 30; - background-color: #f8fafc; - vertical-align: middle; -} - -.ranking-table thead tr:first-child th.student-header { - left: 0; - z-index: 40; - border-right: 2px solid #e2e8f0; - width: 250px; -} - -.ranking-table thead th.global-total-header { - z-index: 25; -} - .sidebar-badge { display: inline-flex; align-items: center; diff --git a/cms/server/admin/static/aws_tp_styles.css b/cms/server/admin/static/aws_tp_styles.css new file mode 100644 index 0000000000..2e4f5276e7 --- /dev/null +++ b/cms/server/admin/static/aws_tp_styles.css @@ -0,0 +1,1451 @@ +/** + * Training Program Styles (aws_tp_styles.css) + * + * This file contains styles specific to training program pages in the admin interface. + * It uses CSS custom properties (variables) for colors to enable easy theming. + */ + +/* ========================================================================== + Color Variables for Theming + ========================================================================== */ + +:root { + /* Primary colors (teal theme) */ + --tp-primary: #0F766E; + --tp-primary-hover: #0d655e; + --tp-primary-light: #14b8a6; + --tp-primary-gradient: linear-gradient(135deg, #0F766E 0%, #14b8a6 100%); + + /* Status colors */ + --tp-success: #059669; + --tp-success-light: #d1fae5; + --tp-success-dark: #065f46; + + --tp-warning: #d97706; + --tp-warning-light: #fef3c7; + --tp-warning-dark: #92400e; + + --tp-danger: #dc2626; + --tp-danger-light: #fef2f2; + --tp-danger-dark: #991b1b; + --tp-danger-border: #fecaca; + + /* Info colors (blue) */ + --tp-info: #3b82f6; + --tp-info-hover: #2563eb; + --tp-info-light: #f0f9ff; + --tp-info-border: #bae6fd; + --tp-info-dark: #0369a1; + + /* Neutral colors */ + --tp-text-primary: #1e293b; + --tp-text-secondary: #374151; + --tp-text-muted: #64748b; + --tp-text-light: #9ca3af; + --tp-text-lighter: #6b7280; + + /* Background colors */ + --tp-bg-white: #ffffff; + --tp-bg-light: #f8fafc; + --tp-bg-gray: #f3f4f6; + --tp-bg-hover: #f9fafb; + + /* Border colors */ + --tp-border: #e5e7eb; + --tp-border-light: #e2e8f0; + --tp-border-dark: #cbd5e1; + + /* Shadow */ + --tp-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --tp-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --tp-shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.1); + --tp-shadow-xl: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +/* ========================================================================== + Page Layout + ========================================================================== */ + +.page-title { + margin-bottom: 24px; +} + +.page-title h1 { + margin: 0; + font-size: 1.75rem; + color: var(--tp-text-primary); +} + +.page-subtitle { + margin-top: 4px; + font-size: 0.9rem; + color: var(--tp-text-muted); +} + +.page-subtitle a { + color: #475569; + font-weight: 600; + text-decoration: none; +} + +.page-subtitle a:hover { + color: var(--tp-primary); +} + +/* ========================================================================== + Action Buttons Row + ========================================================================== */ + +.action-buttons-row { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + padding: 24px 0; + margin-bottom: 16px; +} + +/* Outline button style */ +.btn-outline { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + background: var(--tp-bg-white); + color: var(--tp-text-secondary); + border: 1px solid var(--tp-border); + border-radius: 8px; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; + font-family: inherit; +} + +.btn-outline:hover { + background: var(--tp-bg-hover); + border-color: var(--tp-text-light); + color: var(--tp-text-primary); +} + +.btn-outline svg, +.btn-outline .btn-icon { + width: 18px; + height: 18px; +} + +/* ========================================================================== + Sortable Table Headers + ========================================================================== */ + +.sortable-header { + cursor: pointer; + user-select: none; + transition: color 0.2s; + white-space: nowrap; +} + +.sortable-header:hover { + color: var(--tp-text-primary); + background: var(--tp-bg-light); +} + +.sortable-header::after { + content: '\2195'; + font-size: 1.1em; + margin-left: 5px; + opacity: 0.3; + display: inline-block; + vertical-align: middle; +} + +.sortable-header.sort-asc::after { + content: '\2191'; + opacity: 1; + color: var(--tp-primary); +} + +.sortable-header.sort-desc::after { + content: '\2193'; + opacity: 1; + color: var(--tp-primary); +} + +/* ========================================================================== + Hidden File Input (for file upload buttons) + ========================================================================== */ + +.file-input-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + border: 0; +} + +/* ========================================================================== + Add Student Dropdown/Modal + ========================================================================== */ + +.add-student-dropdown { + position: relative; + display: inline-block; +} + +.add-student-form { + display: none; + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + background: var(--tp-bg-white); + border: 1px solid var(--tp-border); + border-radius: 8px; + padding: 16px; + box-shadow: var(--tp-shadow-lg); + z-index: 100; + min-width: 300px; +} + +.add-student-form.show { + display: block; +} + +.add-student-form label { + display: block; + font-size: 0.85rem; + font-weight: 600; + color: var(--tp-text-secondary); + margin-bottom: 6px; +} + +.add-student-form .searchable-select { + width: 100%; + margin-bottom: 12px; +} + +/* ========================================================================== + Student Table Styles + ========================================================================== */ + +/* Page-specific table overrides */ +#students-table thead th { + position: static !important; + top: auto !important; + left: auto !important; + z-index: auto !important; + box-shadow: none !important; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--tp-text-muted); + background: var(--tp-bg-light); + padding: 14px 16px !important; +} + +#students-table td { + padding: 0; +} + +.student-name-wrapper { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + padding: 8px 10px; +} + +.student-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + background: var(--tp-border); +} + +.student-avatar-placeholder { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--tp-primary-gradient); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + font-size: 0.85rem; + flex-shrink: 0; +} + +.student-info { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; +} + +.student-name-link { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--tp-text-primary); + font-weight: 600; + text-decoration: none; + max-width: 150px; + display: block; +} + +.student-name-link:hover { + color: var(--tp-primary); +} + +.student-username { + font-size: 0.8em; + color: var(--tp-text-muted); + white-space: nowrap; +} + +.student-tag-inline { + font-weight: normal; + font-size: 0.7em; +} + +/* ========================================================================== + Progress Bar (inline style) + ========================================================================== */ + +.progress-bar-inline { + display: flex; + align-items: center; + gap: 8px; +} + +.progress-bar-inline .percentage { + font-size: 0.9rem; + font-weight: 600; + color: #374151; + min-width: 40px; + text-align: right; +} + +.progress-bar-inline .bar-container { + flex: 1; + height: 6px; + background: var(--tp-border); + border-radius: 3px; + overflow: hidden; + width: 120px; +} + +.progress-bar-inline .bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease; +} + +.progress-bar-inline .bar-fill.color-green { + background: var(--tp-success); +} + +.progress-bar-inline .bar-fill.color-orange { + background: var(--tp-warning); +} + +.progress-bar-inline .bar-fill.color-red { + background: var(--tp-danger); +} + +/* ========================================================================== + Icon-only Button (for actions like delete) + ========================================================================== */ + +.btn-icon-only { + background: none; + border: none; + padding: 6px; + cursor: pointer; + color: var(--tp-text-light); + border-radius: 4px; + transition: all 0.2s; +} + +.btn-icon-only:hover { + color: var(--tp-danger); + background: var(--tp-danger-light); +} + +.btn-icon-only:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ========================================================================== + List Cell Wrapper (for table cells) + ========================================================================== */ + +.list-cell-wrapper { + padding: 12px 16px; + display: flex; + align-items: center; +} + +.list-cell-wrapper.center-content { + justify-content: center; +} + +/* ========================================================================== + Form Groups (for modals and forms) + ========================================================================== */ + +.tp-form-group { + margin-bottom: 16px; +} + +.tp-form-group label { + display: block; + font-size: 0.85rem; + font-weight: 600; + color: var(--tp-text-secondary); + margin-bottom: 6px; +} + +.tp-form-group input[type="text"], +.tp-form-group select { + width: 100%; + box-sizing: border-box; +} + +.tp-form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--tp-border); +} + +/* ========================================================================== + Bulk Assign Task Modal (specific styles) + ========================================================================== */ + +.bulk-assign-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 10000; +} + +/* Ensure Tagify dropdown appears above the modal */ +.tagify__dropdown { + z-index: 10001 !important; +} + +.bulk-assign-modal-content { + background: var(--tp-bg-white); + border-radius: 12px; + padding: 24px; + max-width: 480px; + width: 90%; + box-shadow: var(--tp-shadow-xl); +} + +.bulk-assign-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--tp-border); +} + +.bulk-assign-modal-header h2 { + margin: 0; + font-size: 1.25rem; + color: var(--tp-text-primary); +} + +.bulk-assign-close-btn { + background: none; + border: none; + font-size: 28px; + cursor: pointer; + color: var(--tp-text-lighter); + line-height: 1; + padding: 0; +} + +.bulk-assign-close-btn:hover { + color: var(--tp-text-primary); +} + +.bulk-assign-description { + color: var(--tp-text-lighter); + font-size: 0.9rem; + margin: 0 0 20px 0; + padding: 0; +} + +.bulk-assign-form-group { + margin-bottom: 16px; +} + +.bulk-assign-form-group label { + display: block; + font-size: 0.85rem; + font-weight: 600; + color: var(--tp-text-secondary); + margin-bottom: 6px; +} + +.bulk-assign-form-group input[type="text"] { + width: 100%; + box-sizing: border-box; +} + +.bulk-assign-form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--tp-border); +} + +/* ========================================================================== + Text Utilities + ========================================================================== */ + +.text-center { + text-align: center; +} + +.text-left { + text-align: left; +} + +.text-right { + text-align: right; +} + +.text-muted { + color: var(--tp-text-muted); +} + +.text-success { + color: var(--tp-success); +} + +.text-warning { + color: var(--tp-warning); +} + +.text-danger { + color: var(--tp-danger); +} + +/* ========================================================================== + Table Styles + ========================================================================== */ + +/* Common Table Styles */ +.attendance-table, +.ranking-table { + width: auto; + border-collapse: separate; /* Required for sticky headers */ + border-spacing: 0; + font-size: 0.9rem; + white-space: nowrap; +} + +/* Force border-box for consistency */ +.ranking-table *, .attendance-table * { + box-sizing: border-box; +} + +.attendance-table th, +.attendance-table td, +.ranking-table th, +.ranking-table td { + padding: 0; /* Reset padding, inner divs handle it */ + border-bottom: 1px solid var(--tp-border); + border-right: 1px solid var(--tp-border); + background-clip: padding-box; + vertical-align: middle; + height: 1px; +} + +.attendance-table th, +.attendance-table td { + vertical-align: top; +} + +/* --- Sticky Headers & Columns --- */ + +/* 1. Header Rows */ +.attendance-table thead th, +.ranking-table thead th { + position: sticky; + top: 0; + background-color: var(--tp-bg-light); + z-index: 10; + color: var(--tp-text-secondary); + font-weight: 600; + text-align: center; + box-shadow: 0 1px 0 var(--tp-border); /* Visual bottom border */ + padding: 10px 12px; /* Headers get direct padding */ +} + +.ranking-table thead th.student-header { + text-align: left; + vertical-align: middle; +} + +/* 2. First Column (Students) */ +.attendance-table tbody th, +.ranking-table tbody th { + position: sticky; + left: 0; + background-color: var(--tp-bg-white); + z-index: 8; + text-align: left; + min-width: 250px; + max-width: 300px; + box-shadow: 4px 0 8px -2px rgba(0, 0, 0, 0.1); + clip-path: inset(0px -10px 0px 0px); + border-right: 1px solid var(--tp-border); + border-bottom: 1px solid var(--tp-bg-gray); + overflow: hidden; + text-overflow: ellipsis; + color: var(--tp-text-primary); + padding: 0; +} + +/* 3. Top-Left Corner */ +.attendance-table thead tr:first-child th:first-child, +.ranking-table thead tr:first-child th:first-child { + left: 0; + z-index: 20; + background-color: var(--tp-bg-light); + box-shadow: 1px 1px 0 var(--tp-border); +} + +/* --- Attendance Table Specifics --- */ +.date-header-main { + font-size: 0.95rem; + color: var(--tp-text-primary); +} + +.date-header-sub { + font-size: 0.75rem; + color: var(--tp-text-muted); + font-weight: normal; + margin-top: 2px; +} + +/* Wrapper for cell content */ +.cell-wrapper { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 140px; + padding: 10px 12px; +} + +/* Modern Badges */ +.status-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + width: fit-content; +} + +.status-on-time { + background-color: var(--tp-success-light); + color: var(--tp-success-dark); +} + +.status-delayed { + background-color: var(--tp-warning-light); + color: var(--tp-warning-dark); +} + +.status-missed { + background-color: var(--tp-danger-light); + color: var(--tp-danger-dark); +} + +.status-unknown { + background-color: var(--tp-bg-gray); + color: var(--tp-text-light); +} + +.location-row { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + color: var(--tp-text-light); +} + +.location-icon { + font-size: 0.85rem; +} + +.reason-text { + font-size: 0.75rem; + color: var(--tp-text-muted); + background: var(--tp-bg-hover); + border-left: 2px solid var(--tp-border); + padding: 2px 6px; + margin-top: 2px; + white-space: normal; + max-width: 200px; +} + +.attendance-table .empty-cell { + color: var(--tp-border); + font-size: 1.5rem; + line-height: 0.5; + padding: 10px; + text-align: center; +} + +/* Content wrapper for Ranking cells */ +.cell-content { + display: flex; + align-items: center; + justify-content: center; + padding: 12px 8px; + font-size: 1.25rem; /* Much smaller than 1.5em */ + font-weight: 600; + border-radius: 6px; /* Soften the blockiness */ + margin: 4px; /* Give the cell breathing room inside the grid */ + height: calc(100% - 8px); + width: calc(100% - 8px); + color: var(--tp-text-primary); /* Dark Slate, not black */ + background-image: linear-gradient(rgba(255,255,255,0.3), rgba(255,255,255,0.3)); +} + +/* Hover effects */ +.ranking-table tbody tr:hover td { + background-color: var(--tp-bg-hover); +} + +.ranking-table tbody tr:hover th { + background-color: var(--tp-bg-hover); +} + +.ranking-table tbody tr:nth-child(even) { + background-color: var(--tp-bg-light); /* Very light gray */ +} + +/* Badge Styles */ +.badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + font-size: 0.75em; + font-weight: 600; + line-height: 1.2; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: 9999px; + margin-right: 4px; + margin-bottom: 2px; + border: 1px solid transparent; +} + +.badge-pill { + border-radius: 9999px; + padding-left: 8px; + padding-right: 8px; +} + +.badge-teal { + background-color: var(--tp-success-light); + color: var(--tp-success-dark); + border-color: #ccfbf1; +} + +.badge-slate { + background-color: var(--tp-bg-light); + color: var(--tp-text-secondary); + border-color: var(--tp-border); +} + +.ranking-table thead th { + position: sticky; +} + +.ranking-table thead tr:first-child th { + height: auto; + padding: 12px 8px; + background-color: var(--tp-bg-white); + border-bottom: 2px solid var(--tp-border); +} + +.ranking-table thead tr:nth-child(2) th { + top: var(--ranking-header-top); + z-index: 15; + box-shadow: 0 2px 4px -2px rgba(0,0,0,0.1); + background-color: var(--tp-bg-white); +} + +.ranking-table thead tr:first-child th[rowspan="2"] { + top: 0; + height: auto; + z-index: 30; + background-color: var(--tp-bg-light); + vertical-align: middle; +} + +.ranking-table thead tr:first-child th.student-header { + left: 0; + z-index: 40; + border-right: 2px solid var(--tp-border); + width: 250px; +} + +.ranking-table thead th.global-total-header { + z-index: 25; +} + +/* ========================================================================== + Training Program Layout & Components + ========================================================================== */ + +/* Page Constraint */ +.content-constraint { + max-width: 1200px; + margin: 0 0 0 20px; + padding-bottom: 40px; +} + +/* Page Header */ +.tp-page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 15px; +} + +/* Filter Bar */ +.tp-filter-card { + background-color: var(--tp-bg-light); + border: 1px solid var(--tp-border); + border-radius: 8px; + padding: 12px 16px; + margin-bottom: 24px; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.tp-filter-group { + display: flex; + align-items: center; + gap: 8px; +} + +.tp-filter-label { + font-weight: 600; + font-size: 0.85rem; + color: var(--tp-text-secondary); +} + +.tp-filter-input { + padding: 6px 10px; + border: 1px solid var(--tp-border-dark); + border-radius: 4px; + font-size: 0.9rem; + color: var(--tp-text-primary); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.tp-filter-input:focus { + border-color: var(--tp-primary); + box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.1); + outline: none; +} + +/* Buttons */ +.tp-btn-primary { + background-color: var(--tp-primary); + color: white; + border: none; + padding: 6px 16px; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + font-size: 0.9rem; + font-family: inherit; + transition: all 0.2s; + text-decoration: none; + display: inline-block; + line-height: 1.5; + box-sizing: border-box; + vertical-align: middle; +} + +.tp-btn-primary:hover { + background-color: var(--tp-primary-hover); + color: white; +} + +.tp-btn-text { + color: var(--tp-text-lighter); + text-decoration: underline; + font-size: 0.85rem; + margin-left: auto; +} + +.tp-btn-secondary { + background-color: white; + color: var(--tp-primary); + border: 1px solid var(--tp-border-dark); + padding: 6px 16px; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + font-size: 0.9rem; + font-family: inherit; + transition: all 0.2s; +} + +.btn-filled-primary { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + background: var(--tp-primary); + color: white; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; + font-family: inherit; +} + +.btn-filled-primary:hover { + background: var(--tp-primary-hover); + color: white; +} + +/* Data Table Wrapper */ +.tp-table-container { + overflow-x: auto; + max-height: 75vh; + border: 1px solid var(--tp-border-light); + border-radius: 8px; +} + +/* ========================================================================== + Training Day & Ranking Table Styles + ========================================================================== */ + +/* Training Day Headers */ +.training-day-header { + background-color: var(--tp-bg-light) !important; + text-align: center; + border-bottom: 1px solid var(--tp-border-dark) !important; + pointer-events: none; +} + +.global-total-header { + background-color: var(--tp-bg-light) !important; + color: var(--tp-text-primary); + font-weight: 800; + border-left: none !important; + border-right: 1px solid var(--tp-border-light) !important; +} + +.ranking-table td.global-cell { + background-color: var(--tp-bg-gray) !important; + font-weight: 700; + border-left: none !important; + border-right: 1px solid var(--tp-border-light) !important; +} + +.global-total-header::after, +.ranking-table td.global-cell::after { + content: none !important; + display: none !important; +} + +.training-day-header-main { + font-size: 0.95rem; + color: var(--tp-text-primary); + font-weight: 700; +} + +.training-day-header-sub { + font-size: 0.75rem; + color: var(--tp-text-muted); + font-weight: normal; + margin-top: 2px; +} + +.task-header { + background-color: var(--tp-bg-white) !important; + font-size: 0.8rem; + color: var(--tp-text-secondary); + vertical-align: bottom; + border-left: 1px solid var(--tp-border-light) !important; + border-top: 3px solid var(--tp-border-light); +} + +/* Total Column specific styling */ +.total-col { + border-left: 2px solid var(--tp-border-light) !important; + background-color: var(--tp-bg-light); +} + +.ranking-table thead th.total-col { + background-color: var(--tp-bg-light) !important; + color: var(--tp-text-primary); + font-weight: 800; +} + +.ranking-table tbody td.total-col { + background-color: var(--tp-bg-light) !important; + font-weight: 700; + color: var(--tp-text-primary); +} + +.ranking-table th.training-day-header, +.ranking-table th.total-col, +.ranking-table td.total-col { + border-right: none !important; +} + +.ranking-table th.training-day-header, +.ranking-table th.total-col { + position: sticky; + z-index: 11; +} + +.ranking-table td.total-col { + position: relative; + z-index: 5; +} + +.ranking-table th.training-day-header::after, +.ranking-table th.total-col::after, +.ranking-table td.total-col::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + right: 0; + width: 4px; + background-color: var(--tp-border-dark); + z-index: 10; +} + +.ranking-table .empty-cell-content { + color: var(--tp-border-dark); + text-align: center; + font-weight: normal; +} + +.inaccessible-cell { + background-color: var(--tp-bg-gray); + background-image: radial-gradient(var(--tp-border) 1px, transparent 1px); + background-size: 10px 10px; + color: var(--tp-text-light); +} + +/* Attendance badges for combined ranking */ +.attendance-badge { + margin-left: 4px; + font-size: 0.75em; + vertical-align: middle; +} + +.home-badge { + opacity: 0.7; +} + +/* ========================================================================== + Navigation & Header Components + ========================================================================== */ + +/* Breadcrumb Navigation */ +.nav-breadcrumb { + margin-bottom: 12px; +} + +.nav-back-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--tp-text-muted); + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; + transition: color 0.2s; +} + +.nav-back-link:hover { + color: var(--tp-primary); +} + +/* ========================================================================== + Progress Card Styles + ========================================================================== */ + +.progress-card { + background: var(--tp-bg-white); + border: 1px solid var(--tp-border); + border-radius: 12px; + padding: 24px; + margin-bottom: 24px; + box-shadow: var(--tp-shadow-sm); +} + +.progress-card-header { + font-size: 0.85rem; + font-weight: 600; + color: var(--tp-text-lighter); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--tp-bg-gray); +} + +.progress-item { + padding: 16px 0; + border-bottom: 1px solid var(--tp-bg-gray); +} + +.progress-item:last-child { + border-bottom: none; +} + +/* ========================================================================== + Source Badges + ========================================================================== */ + +.source-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 4px; + font-size: 1em; + font-weight: 600; + line-height: 1; + white-space: nowrap; + width: fit-content; +} + +.badge-training { + background: var(--tp-success-light); + color: var(--tp-success-dark); + border: 1px solid #bbf7d0; +} + +.badge-training:hover { + background: #dcfce7; + text-decoration: none; +} + +.badge-archived { + background: var(--tp-warning-light); + color: var(--tp-warning-dark); + border: 1px solid #fde68a; +} + +.badge-manual { + background: var(--tp-bg-light); + color: var(--tp-text-muted); + border: 1px solid var(--tp-border); +} + +/* ========================================================================== + Histogram Modal Styles + ========================================================================== */ + +.histogram-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 10000; +} + +.histogram-modal-content { + background: var(--tp-bg-white); + border-radius: 12px; + padding: 24px; + max-width: 800px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + box-shadow: var(--tp-shadow-xl); +} + +.histogram-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid var(--tp-border); +} + +.histogram-modal-header h2 { + margin: 0; + font-size: 1.25rem; + color: var(--tp-text-primary); +} + +.histogram-close-btn { + background: none; + border: none; + font-size: 28px; + cursor: pointer; + color: var(--tp-text-lighter); + line-height: 1; + padding: 0; +} + +.histogram-close-btn:hover { + color: var(--tp-text-primary); +} + +.histogram-filter-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +.histogram-filter-row label { + font-weight: 500; + color: var(--tp-text-secondary); + white-space: nowrap; +} + +.histogram-chart-container { + background: var(--tp-bg-light); + border-radius: 8px; + padding: 20px; + margin-bottom: 16px; +} + +.histogram-bars { + display: flex; + align-items: flex-end; + justify-content: space-between; + height: 200px; + gap: 4px; +} + +.histogram-bar-container { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; +} + +.histogram-bar-wrapper { + flex: 1; + display: flex; + flex-direction: column; + justify-content: flex-end; + width: 100%; +} + +.histogram-bar { + width: 100%; + min-height: 2px; + border-radius: 4px 4px 0 0; + transition: height 0.3s ease; +} + +.histogram-label { + font-size: 10px; + color: var(--tp-text-lighter); + margin-top: 4px; + text-align: center; +} + +.histogram-count { + font-size: 11px; + font-weight: 600; + color: var(--tp-text-secondary); + margin-top: 2px; +} + +.histogram-stats { + background: var(--tp-info-light); + border: 1px solid var(--tp-info-border); + border-radius: 6px; + padding: 12px 16px; + margin-bottom: 16px; + font-size: 0.9rem; + color: var(--tp-info-dark); +} + +.histogram-text-section { + border: 1px solid var(--tp-border); + border-radius: 8px; + overflow: hidden; +} + +.histogram-text-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + background: var(--tp-bg-light); + border-bottom: 1px solid var(--tp-border); +} + +.histogram-text-header span { + font-weight: 500; + color: var(--tp-text-secondary); +} + +.copy-btn { + background: var(--tp-info); + color: white; + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 0.85rem; +} + +.copy-btn:hover { + background: var(--tp-info-hover); +} + +#histogramTextData { + width: 100%; + border: none; + padding: 12px 14px; + font-family: monospace; + font-size: 12px; + resize: vertical; + background: var(--tp-bg-white); +} + +.histogram-icon { + cursor: pointer; + font-size: 12px; + margin-left: 4px; + opacity: 0.6; + transition: opacity 0.2s; + vertical-align: middle; +} + +.histogram-icon:hover { + opacity: 1; +} + +.task-header .header-text, +.total-col-header .header-text { + vertical-align: middle; +} + +/* ========================================================================== + Combined Ranking Page Specific Styles + ========================================================================== */ + +.combined-ranking-student-wrapper { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + padding: 6px 10px; +} + +.combined-ranking-student-info { + flex: 1; + min-width: 0; +} + +.combined-ranking-name-row { + display: flex; + align-items: baseline; + gap: 6px; + max-width: 100%; +} + +.combined-ranking-name-link { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; + max-width: 100%; +} + +.combined-ranking-tags { + margin-top: 3px; + display: flex; + flex-wrap: wrap; + gap: 2px; +} + +.combined-ranking-tag { + font-weight: normal; + font-size: 0.7em; +} + +.history-link { + margin-left: auto; + text-decoration: none; +} + +.inaccessible-cell span { + opacity: 0.5; +} + +.empty-state-box { + text-align: center; + padding: 40px; + background: var(--tp-bg-light); + border-radius: 8px; + border: 1px dashed var(--tp-border-dark); + margin-top: 20px; +} + +.empty-state-box h3 { + color: var(--tp-text-secondary); + margin-bottom: 8px; + font-weight: 600; +} + +.empty-state-box p { + color: var(--tp-text-lighter); +} diff --git a/cms/server/admin/templates/base.html b/cms/server/admin/templates/base.html index 4eb9ffce5f..465b7d3920 100644 --- a/cms/server/admin/templates/base.html +++ b/cms/server/admin/templates/base.html @@ -52,6 +52,7 @@ + @@ -487,7 +488,7 @@