From 604f836b8847855656f612bf6d09a03868ffe19c Mon Sep 17 00:00:00 2001 From: Andrea Callia D'Iddio <ac4014@ic.ac.uk> Date: Tue, 28 Feb 2023 22:36:14 +0000 Subject: [PATCH] Feat: support deadline by category. --- app/constants.py | 3 - app/forms/project.py | 17 ++- app/messages/__init__.py | 5 + app/messages/message_builders.py | 24 +++- app/models/category.py | 10 ++ app/templates/pages/staff/project-view.html | 6 +- .../pages/staff/shortlisted-projects.html | 28 +++-- .../pages/student/shortlisted-projects.html | 26 ++-- app/utils/decorators.py | 33 +---- app/views/staff.py | 116 ++++++++++++------ app/views/student.py | 85 +++++++++---- 11 files changed, 227 insertions(+), 126 deletions(-) diff --git a/app/constants.py b/app/constants.py index ee5021c..4c37a3f 100644 --- a/app/constants.py +++ b/app/constants.py @@ -2,6 +2,3 @@ from datetime import datetime STUDENT = "student" STAFF = "staff" - -DEADLINE_FOR_STUDENT_CHANGES = datetime(2023, 2, 9, 19, 0) -DEADLINE_FOR_STAFF_CHANGES = datetime(2025, 2, 9, 19, 0) diff --git a/app/forms/project.py b/app/forms/project.py index 9cddae3..bd95213 100644 --- a/app/forms/project.py +++ b/app/forms/project.py @@ -8,15 +8,26 @@ from wtforms import ( TextAreaField, validators, ) -from wtforms.validators import DataRequired, ValidationError +from wtforms.validators import DataRequired, StopValidation, ValidationError + + +def empty_to_none(form, field): + if field.data == "": + field.data = None + else: + raise StopValidation() class ProjectForm(FlaskForm): title = StringField("Title", validators=[DataRequired()]) description = TextAreaField("Description", validators=[DataRequired()]) - background_skills = TextAreaField("Background skills") + background_skills = TextAreaField( + "Background skills", validators=[empty_to_none, validators.Optional()] + ) is_student_proposal = BooleanField("Student Proposal", default=False) - on_behalf = StringField("from", default=None) + on_behalf = StringField( + "from", default=None, validators=[empty_to_none, validators.Optional()] + ) category = SelectField("Category", coerce=str) meeting_modes = [("", "N/A"), ("remote", "Remote"), ("in-person", "In person")] meeting_mode = SelectField( diff --git a/app/messages/__init__.py b/app/messages/__init__.py index 62f5274..56fa876 100644 --- a/app/messages/__init__.py +++ b/app/messages/__init__.py @@ -6,6 +6,8 @@ from .message_builders import ( login_unsuccessful_error, project_not_found, shortlist_not_found, + successful_staff_ranking_update, + successful_student_ranking_update, ) ######################################################################### @@ -22,3 +24,6 @@ INVALID_CATEGORY = invalid_category() DEADLINE_EXPIRED_FOR_STUDENTS = deadline_expired_for_students() DEADLINE_EXPIRED_FOR_STAFF = deadline_expired_for_staff() + +SUCCESSFUL_STAFF_RANKING_UPDATE = successful_staff_ranking_update() +SUCCESSFUL_STUDENT_RANKING_UPDATE = successful_student_ranking_update() diff --git a/app/messages/message_builders.py b/app/messages/message_builders.py index 8caa642..5caa94b 100644 --- a/app/messages/message_builders.py +++ b/app/messages/message_builders.py @@ -1,6 +1,6 @@ from markupsafe import Markup -from .styles import ERROR, INFO +from .styles import ERROR, INFO, SUCCESS class Message: @@ -80,7 +80,7 @@ def deadline_expired_for_students(): return MessageEncoder.encode( Message( header="<h3><strong>Deadline expired</strong></h3>", - body="<p>The deadline to make changes has now expired, and you cannot make changes to your shortlisting and ranking. If you have questions, please contact Lorenzo Picinali.</p>", + body="<p>The deadline to make changes has now expired, and you cannot make changes to your shortlisting and ranking. If you have questions, please contact Andrea Callia D'Iddio.</p>", style=ERROR, ) ) @@ -94,3 +94,23 @@ def deadline_expired_for_staff(): style=ERROR, ) ) + + +def successful_staff_ranking_update(): + return MessageEncoder.encode( + Message( + header="<h3><strong>Ranking updated</strong></h3>", + body="<p>The ranking has been updated.</p>", + style=SUCCESS, + ) + ) + + +def successful_student_ranking_update(): + return MessageEncoder.encode( + Message( + header="<h3><strong>Ranking updated</strong></h3>", + body="<p>The ranking has been updated.</p>", + style=SUCCESS, + ) + ) diff --git a/app/models/category.py b/app/models/category.py index d5262bb..a741776 100644 --- a/app/models/category.py +++ b/app/models/category.py @@ -1,3 +1,5 @@ +from datetime import datetime + from ..database import db @@ -10,3 +12,11 @@ class Category(db.Model): @property def name(self) -> str: return self.title if self.title else self.code + + @property + def is_deadline_expired_for_staff(self): + return datetime.utcnow() > self.deadline_for_staff_change + + @property + def is_deadline_expired_for_students(self): + return datetime.utcnow() > self.deadline_for_student_changes diff --git a/app/templates/pages/staff/project-view.html b/app/templates/pages/staff/project-view.html index 15d3073..51969f8 100644 --- a/app/templates/pages/staff/project-view.html +++ b/app/templates/pages/staff/project-view.html @@ -22,7 +22,7 @@ {% if project.background_skills %} {{ project_details_section('Background skills', project.background_skills) }} {% endif %} - {% if project.is_remote %} + {% if project.is_remote != None %} {{ project_details_section('Meeting mode', "Remote" if project.is_remote else "In person") }} {% endif %} {% if project.proposed_start_date %} @@ -31,10 +31,10 @@ {% if project.duration_in_weeks %} {{ project_details_section('Duration in weeks', project.duration_in_weeks) }} {% endif %} - {% if project.is_part_time %} + {% if project.is_part_time != None %} {{ project_details_section('Time commitment', "Part-time" if project.is_part_time else "Full-time") }} {% endif %} - {% if project.is_lab_based %} + {% if project.is_lab_based != None %} {{ project_details_section('Lab usage', "Lab-based" if project.is_lab_based else "Not lab-based") }} {% endif %} </div> diff --git a/app/templates/pages/staff/shortlisted-projects.html b/app/templates/pages/staff/shortlisted-projects.html index 4e270b5..8c1c6db 100644 --- a/app/templates/pages/staff/shortlisted-projects.html +++ b/app/templates/pages/staff/shortlisted-projects.html @@ -9,22 +9,27 @@ <h1 class="w3-center w3-text-{{ theme_colour }}">Who wants to do a project with you</h1> {% if projects %} <ul class="w3-ul" id="projects"> - {% for shortlisting in projects %} - <li class="w3-display-container" data-id="{{ shortlisting.id }}"> - <span class="w3-bar-item sort-list-handle"> - <i class="fa-solid fa-bars"></i>  - </span> - <span> - {{ shortlisting.project.title }} - {{ full_names[shortlisting.id] }} - </span> - </li> - {% endfor %} + {% for category in categories %} + {% if not category.is_deadline_expired_for_staff %} + <h2 class="w3-center w3-text-{{ theme_colour }}">{{ category.title }}</h2> + {% for shortlisting in projects[category.code] %} + <li class="w3-display-container" data-group="{{ category.code }}" data-id="{{ shortlisting.id }}" data-handle=".sort-list-handle"> + <span class="w3-bar-item sort-list-handle"> + <i class="fa-solid fa-bars"></i>  + </span> + <span> + {{ shortlisting.project.title }} - {{ full_names[shortlisting.id] }} + </span> + </li> + {% endfor %} + {% endif %} + {% endfor %} </ul> - </section> <button id="save-button" type="button" class="w3-btn w3-{{ theme_colour }} w3-right">Save</button> {% else %} {{ utils.placeholder_text("No projects to show yet.") }} {% endif %} + </section> </div> </div> @@ -37,6 +42,7 @@ const sortableProjectList = Sortable.create(projectList, { handle: ".sort-list-handle", ghostClass: "ghost", + onMove: function(evt) {return (evt.dragged.getAttribute("data-group") === evt.related.getAttribute("data-group")) ;} }) const saveButton = document.getElementById("save-button") saveButton.addEventListener("click", async () => { diff --git a/app/templates/pages/student/shortlisted-projects.html b/app/templates/pages/student/shortlisted-projects.html index d0e9024..2e207cc 100644 --- a/app/templates/pages/student/shortlisted-projects.html +++ b/app/templates/pages/student/shortlisted-projects.html @@ -9,16 +9,21 @@ <h1 class="w3-center w3-text-blue">Your shortlisted projects</h1> {% if projects %} <ul class="w3-ul" id="projects"> - {% for project in projects %} - <li class="w3-display-container" data-id="{{ project.id }}"> - <span class="w3-bar-item sort-list-handle"> - <i class="fa-solid fa-bars"></i>  - </span> - <span> - {{ project.title }} - {{ full_names[project.id] }} - </span> - </li> - {% endfor %} + {% for category in categories %} + {% if not category.is_deadline_expired_for_students %} + <h2 class="w3-center w3-text-{{ theme_colour }}">{{ category.title }}</h2> + {% for project in projects[category.code] %} + <li class="w3-display-container" data-group="{{ category.code }}" data-id="{{ project.id }}" data-handle=".sort-list-handle"> + <span class="w3-bar-item sort-list-handle"> + <i class="fa-solid fa-bars"></i>  + </span> + <span> + {{ project.title }} - {{ full_names[project.id] }} + </span> + </li> + {% endfor %} + {% endif %} + {% endfor %} </ul> </section> <button id="save-button" type="button" class="w3-btn w3-{{ theme_colour }} w3-right">Save</button> @@ -37,6 +42,7 @@ const sortableProjectList = Sortable.create(projectList, { handle: ".sort-list-handle", ghostClass: "ghost", + onMove: function(evt) {return (evt.dragged.getAttribute("data-group") === evt.related.getAttribute("data-group")) ;} }) const saveButton = document.getElementById("save-button") saveButton.addEventListener("click", async () => { diff --git a/app/utils/decorators.py b/app/utils/decorators.py index 9940ece..787852c 100644 --- a/app/utils/decorators.py +++ b/app/utils/decorators.py @@ -1,17 +1,10 @@ -from datetime import datetime from functools import wraps -from flask import flash, url_for +from flask import url_for from flask_login import current_user from werkzeug.utils import redirect -from app import messages -from app.constants import ( - DEADLINE_FOR_STAFF_CHANGES, - DEADLINE_FOR_STUDENT_CHANGES, - STAFF, - STUDENT, -) +from app.constants import STAFF, STUDENT def staff_only(func): @@ -32,25 +25,3 @@ def students_only(func): return redirect(url_for(f"{current_user.role}.projects")) return inner - - -def only_before_deadline_for_students(func): - @wraps(func) - def inner(*args, **kwargs): - if datetime.utcnow() <= DEADLINE_FOR_STUDENT_CHANGES: - return func(*args, **kwargs) - flash(messages.DEADLINE_EXPIRED_FOR_STUDENTS) - return redirect(url_for(f"{current_user.role}.projects")) - - return inner - - -def only_before_deadline_for_staff(func): - @wraps(func) - def inner(*args, **kwargs): - if datetime.utcnow() <= DEADLINE_FOR_STAFF_CHANGES: - return func(*args, **kwargs) - flash(messages.DEADLINE_EXPIRED_FOR_STAFF) - return redirect(url_for(f"{current_user.role}.projects")) - - return inner diff --git a/app/views/staff.py b/app/views/staff.py index 96b7a8a..b2c4b84 100644 --- a/app/views/staff.py +++ b/app/views/staff.py @@ -5,14 +5,13 @@ from flask_login import current_user, login_required from sqlalchemy import and_ from app import messages -from app.constants import DEADLINE_FOR_STAFF_CHANGES from app.database import db from app.forms.project import ProjectForm from app.models.category import Category from app.models.person import Person from app.models.project import Project from app.models.shortlist import Shortlisting -from app.utils.decorators import only_before_deadline_for_staff, staff_only +from app.utils.decorators import staff_only bp = Blueprint("staff", __name__, url_prefix="/staff") @@ -47,7 +46,6 @@ def projects(): deadlines: dict[str, datetime] = { c.code: c.deadline_for_staff_change for c in categories } - allow_edit = True if datetime.utcnow() <= DEADLINE_FOR_STAFF_CHANGES else False own_projects: list[Project] = ( Project.query.filter_by(proposer=current_user.username) .filter(Project.category.in_(category_codes)) @@ -72,9 +70,8 @@ def projects(): ) -def is_deadline_expired_for_staff(category_code): - category = Category.query.get(category_code) - return datetime.utcnow() > category.deadline_for_staff_change +def equals_or_none(first, second): + return first == second if first is not None else None @bp.route("/projects/create", methods=["GET", "POST"]) @@ -87,7 +84,8 @@ def create_project(): categories: list[Category] = Category.query.filter( Category.code.in_(category_codes) ).all() - form = ProjectForm() + project = Project(proposer=current_user.username) + form = ProjectForm(obj=project) form.category.choices = [("", "Choose an option")] + [ (c.code, c.name) for c in categories ] @@ -95,31 +93,17 @@ def create_project(): project = Project( title=form.title.data, description=form.description.data, - background_skills=form.background_skills.data - if form.background_skills.data not in [None, ""] - else None, + background_skills=form.background_skills.data, proposer=current_user.username, - on_behalf=form.on_behalf.data if form.is_student_proposal.data else None, + on_behalf=form.on_behalf.data, category=form.category.data, - is_remote=( - form.meeting_mode.data == "remote" - if form.meeting_mode.data is not None - else None - ), - is_part_time=( - form.time_commitment.data == "part-time" - if form.time_commitment.data is not None - else None - ), - is_lab_based=( - form.lab_usage.data == "lab-based" - if form.lab_usage.data is not None - else None - ), + is_remote=equals_or_none(form.meeting_mode.data, "remote"), + is_part_time=equals_or_none(form.time_commitment.data, "part-time"), + is_lab_based=equals_or_none(form.lab_usage.data, "lab-based"), proposed_start_date=form.proposed_start_date.data, duration_in_weeks=form.duration_in_weeks.data, ) - if is_deadline_expired_for_staff(form.category.data): + if Category.query.get(form.category.data).is_deadline_expired_for_staff: flash(messages.DEADLINE_EXPIRED_FOR_STAFF) else: db.session.add(project) @@ -143,7 +127,9 @@ def delete_project(project_id): Person.query.filter_by(username=current_user.username).first().categories ) project = Project.query.get(project_id) - if (project is not None) and is_deadline_expired_for_staff(project.category): + if (project is not None) and Category.query.get( + project.category + ).is_deadline_expired_for_staff: flash(messages.DEADLINE_EXPIRED_FOR_STAFF) elif ( (project is not None) @@ -160,12 +146,12 @@ def delete_project(project_id): @bp.route("/projects/<project_id>/edit", methods=["GET", "POST"]) @login_required @staff_only -@only_before_deadline_for_staff def edit_project(project_id): # show_box is a boolean used to decide if we show the text box for student full name. by default this is not shown show_box = False person = None can_edit = True + deadline_expired = False category_codes: list[str] = ( Person.query.filter_by(username=current_user.username).first().categories ) @@ -177,6 +163,9 @@ def edit_project(project_id): can_edit = False elif project.category not in category_codes: can_edit = False + elif Category.query.get(project.category).is_deadline_expired_for_staff: + can_edit = False + deadline_expired = True if can_edit: form = ProjectForm() form.category.choices = [(c.code, c.name) for c in categories] @@ -186,18 +175,44 @@ def edit_project(project_id): if form.validate_on_submit(): project.title = form.title.data project.description = form.description.data + project.background_skills = form.background_skills.data project.on_behalf = ( form.on_behalf.data if form.is_student_proposal.data else None ) project.category = form.category.data + project.is_remote = equals_or_none(form.meeting_mode.data, "remote") + project.is_part_time = equals_or_none( + form.time_commitment.data, "part-time" + ) + project.is_lab_based = equals_or_none(form.lab_usage.data, "lab-based") + project.proposed_start_date = form.proposed_start_date.data + project.duration_in_weeks = form.duration_in_weeks.data db.session.commit() if project.on_behalf and on_behalf_changed: shortlist_proposer(project) return redirect(url_for("staff.projects")) form = ProjectForm(obj=project) - form.meeting_mode.data = "remote" if project.is_remote is True else "" - form.time_commitment.data = "part-time" if project.is_part_time is True else "" - form.lab_usage.data = "lab-based" if project.is_lab_based is True else "" + form.meeting_mode.data = ( + "remote" + if project.is_remote is True + else "in-person" + if project.is_remote is False + else "" + ) + form.time_commitment.data = ( + "part-time" + if project.is_part_time is True + else "full-time" + if project.is_part_time is False + else "" + ) + form.lab_usage.data = ( + "lab-based" + if project.is_lab_based is True + else "non-lab-based" + if project.is_lab_based is False + else "" + ) form.category.choices = [(c.code, c.name) for c in categories] if project.on_behalf is not None: @@ -212,7 +227,10 @@ def edit_project(project_id): show_box=show_box, person=person, ) - flash(messages.PROJECT_NOT_FOUND) + if deadline_expired: + flash(messages.DEADLINE_EXPIRED_FOR_STAFF) + else: + flash(messages.PROJECT_NOT_FOUND) return redirect(url_for("staff.projects")) @@ -245,7 +263,15 @@ def projects_ranking(): .all() ) + category_codes: list[str] = ( + Person.query.filter_by(username=current_user.username).first().categories + ) + categories: list[Category] = Category.query.filter( + Category.code.in_(category_codes) + ).all() + full_names = {} + projects_by_category = {c: [] for c in category_codes} for shortlisting in own_projects: person = Person.query.filter( @@ -256,9 +282,13 @@ def projects_ranking(): person.full_name if person.full_name else person.username ) + if shortlisting.project.category in projects_by_category: + projects_by_category[shortlisting.project.category].append(shortlisting) + return render_template( "pages/staff/shortlisted-projects.html", - projects=own_projects, + projects=projects_by_category, + categories=categories, full_names=full_names, ) @@ -266,7 +296,6 @@ def projects_ranking(): @bp.route("/projects/rankings", methods=["PUT"]) @login_required @staff_only -@only_before_deadline_for_staff def update_ranking(): shortlisting_ids = request.json current_shortlist: list[Shortlisting] = Shortlisting.query.join(Project).filter( @@ -275,7 +304,22 @@ def update_ranking(): Project.proposer == current_user.username, ) ) + deadline_errors = False for shortlisting in current_shortlist: - shortlisting.staff_ranking = shortlisting_ids.index(shortlisting.id) + 1 + if Category.query.get( + Project.query.get(shortlisting.project_id).category + ).is_deadline_expired_for_staff: + if ( + shortlisting.staff_ranking + != shortlisting_ids.index(shortlisting.id) + 1 + ): + deadline_errors = True + else: + shortlisting.staff_ranking = shortlisting_ids.index(shortlisting.id) + 1 + if deadline_errors: + flash(messages.DEADLINE_EXPIRED_FOR_STAFF) + return redirect(url_for(f"{current_user.role}.projects")) + else: + flash(messages.SUCCESSFUL_STAFF_RANKING_UPDATE) db.session.commit() return "", 204 diff --git a/app/views/student.py b/app/views/student.py index a89a2a2..8588be3 100644 --- a/app/views/student.py +++ b/app/views/student.py @@ -4,10 +4,11 @@ from sqlalchemy import or_ from app import messages from app.database import db +from app.models.category import Category from app.models.person import Person from app.models.project import Project from app.models.shortlist import Shortlisting -from app.utils.decorators import only_before_deadline_for_students, students_only +from app.utils.decorators import students_only bp = Blueprint("student", __name__, url_prefix="/student") @@ -47,7 +48,15 @@ def projects_ranking(): .all() ) + category_codes: list[str] = ( + Person.query.filter_by(username=current_user.username).first().categories + ) + categories: list[Category] = Category.query.filter( + Category.code.in_(category_codes) + ).all() + full_names = {} + projects_by_category = {c: [] for c in category_codes} for shortlisting in shortlisted_projects: username = ( @@ -64,9 +73,13 @@ def projects_ranking(): person.full_name if person.full_name else person.username ) + if shortlisting.category in projects_by_category: + projects_by_category[shortlisting.category].append(shortlisting) + return render_template( "pages/student/shortlisted-projects.html", - projects=shortlisted_projects, + projects=projects_by_category, + categories=categories, full_names=full_names, ) @@ -74,14 +87,25 @@ def projects_ranking(): @bp.route("/projects/rankings", methods=["PUT"]) @login_required @students_only -@only_before_deadline_for_students def update_ranking(): project_ids = request.json current_shortlist: list[Shortlisting] = Shortlisting.query.filter( Shortlisting.project_id.in_(project_ids) ).filter_by(student=current_user.username) + deadline_errors = False for shortlisting in current_shortlist: - shortlisting.ranking = project_ids.index(shortlisting.project_id) + 1 + if Category.query.get( + Project.query.get(shortlisting.project_id).category + ).is_deadline_expired_for_students: + if shortlisting.ranking != project_ids.index(shortlisting.project_id) + 1: + deadline_errors = True + else: + shortlisting.ranking = project_ids.index(shortlisting.project_id) + 1 + if deadline_errors: + flash(messages.DEADLINE_EXPIRED_FOR_STUDENTS) + return redirect(url_for(f"{current_user.role}.projects")) + else: + flash(messages.SUCCESSFUL_STUDENT_RANKING_UPDATE) db.session.commit() return "", 204 @@ -116,44 +140,51 @@ def view_project(project_id): @bp.route("/projects/<project_id>/shortlist") @login_required @students_only -@only_before_deadline_for_students def shortlist_project(project_id): can_shortlist = True category_codes: list[str] = ( Person.query.filter_by(username=current_user.username).first().categories ) project = Project.query.get(project_id) - if not project: - can_shortlist = False - elif project.category not in category_codes: - can_shortlist = False - if can_shortlist: - shortlisting = Shortlisting( - student=current_user.username, project_id=project_id - ) - db.session.add(shortlisting) - db.session.commit() + if (project is not None) and Category.query.get( + project.category + ).is_deadline_expired_for_students: + flash(messages.DEADLINE_EXPIRED_FOR_STUDENTS) else: - flash(messages.PROJECT_NOT_FOUND) + if not project: + can_shortlist = False + elif project.category not in category_codes: + can_shortlist = False + if can_shortlist: + shortlisting = Shortlisting( + student=current_user.username, project_id=project_id + ) + db.session.add(shortlisting) + db.session.commit() + else: + flash(messages.PROJECT_NOT_FOUND) return redirect(url_for("student.projects")) @bp.route("/projects/<project_id>/unshortlist") @login_required @students_only -@only_before_deadline_for_students def unshortlist_project(project_id): - if Project.query.get(project_id): - shortlist: Shortlisting = ( - Shortlisting.query.filter(Shortlisting.project_id == project_id) - .filter_by(student=current_user.username) - .first() - ) - if shortlist: - db.session.delete(shortlist) - db.session.commit() + project = Project.query.get(project_id) + if project: + if Category.query.get(project.category).is_deadline_expired_for_students: + flash(messages.DEADLINE_EXPIRED_FOR_STUDENTS) else: - flash(messages.SHORTLIST_NOT_FOUND) + shortlist: Shortlisting = ( + Shortlisting.query.filter(Shortlisting.project_id == project_id) + .filter_by(student=current_user.username) + .first() + ) + if shortlist: + db.session.delete(shortlist) + db.session.commit() + else: + flash(messages.SHORTLIST_NOT_FOUND) else: flash(messages.PROJECT_NOT_FOUND) return redirect(url_for("student.projects")) -- GitLab