Skip to content

Commit

Permalink
Merge pull request #1525 from emfcamp/shift-management
Browse files Browse the repository at this point in the history
Shift management
  • Loading branch information
jellybob authored May 15, 2024
2 parents 179252c + 7bd7187 commit b140235
Show file tree
Hide file tree
Showing 18 changed files with 297 additions and 144 deletions.
37 changes: 21 additions & 16 deletions apps/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,7 @@ def collect(self):
)

gauge_groups(
emf_shifts,
ShiftEntry.query.join(ShiftEntry.shift).join(Shift.role),
Role.name,
case(
(ShiftEntry.completed, "completed"),
(ShiftEntry.abandoned, "abandoned"),
(ShiftEntry.arrived, "arrived"),
else_="signed_up",
),
emf_shifts, ShiftEntry.query.join(ShiftEntry.shift).join(Shift.role), Role.name, ShiftEntry.state
)

shift_seconds = (
Expand All @@ -113,20 +105,33 @@ def collect(self):
.with_entities(
func.sum(Shift.duration).label("minimum"),
Role.name,
case(
(ShiftEntry.completed, "completed"),
(ShiftEntry.abandoned, "abandoned"),
(ShiftEntry.arrived, "arrived"),
else_="signed_up",
),
ShiftEntry.state,
)
.group_by(Role.name, ShiftEntry.completed, ShiftEntry.abandoned, ShiftEntry.arrived)
.group_by(Role.name, ShiftEntry.state)
.order_by(Role.name)
)

for duration, *key in shift_seconds:
emf_shift_seconds.add_metric(key, duration.total_seconds())

required_shift_seconds = (
Shift.query.join(Shift.role)
.with_entities(
func.sum(Shift.duration * Shift.min_needed).label("minimum_secs"),
func.sum(Shift.duration * Shift.max_needed).label("maximum_secs"),
func.sum(Shift.min_needed).label("minimum"),
func.sum(Shift.max_needed).label("maximum"),
Role.name,
)
.group_by(Role.name)
.order_by(Role.name)
)
for min_sec, max_sec, min, max, role in required_shift_seconds:
emf_shift_seconds.add_metric([role, "min_required"], min_sec.total_seconds())
emf_shift_seconds.add_metric([role, "max_required"], max_sec.total_seconds())
emf_shifts.add_metric([role, "min_required"], min)
emf_shifts.add_metric([role, "max_required"], max)

return [
emf_purchases,
emf_payments,
Expand Down
79 changes: 47 additions & 32 deletions apps/volunteer/choose_roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
from main import db
from models.volunteer.role import Role
from models.volunteer.volunteer import Volunteer as VolunteerUser
from models.volunteer.shift import Shift, ShiftEntry
from models.volunteer.shift import (
Shift,
ShiftEntry,
ShiftEntryStateException,
)

from . import volunteer, v_user_required
from ..common import feature_enabled, feature_flag
Expand Down Expand Up @@ -168,10 +172,16 @@ def role_admin_required(f, *args, **kwargs):
@volunteer.route("role/<int:role_id>/admin")
@role_admin_required
def role_admin(role_id):
# Allow mocking the time for testing.
if "now" in request.args:
now = datetime.strptime(request.args["now"], "%Y-%m-%dT%H:%M")
else:
now = datetime.now()

limit = int(request.args.get("limit", "5"))
offset = int(request.args.get("offset", "0"))
role = Role.query.get_or_404(role_id)
cutoff = datetime.now() - timedelta(minutes=30)
cutoff = now - timedelta(minutes=30)
shifts = (
Shift.query.filter_by(role=role)
.filter(Shift.end >= cutoff)
Expand All @@ -180,58 +190,63 @@ def role_admin(role_id):
.limit(limit)
.all()
)

active_shift_entries = (
ShiftEntry.query.filter(ShiftEntry.state == "arrived")
.join(ShiftEntry.shift)
.filter(Shift.role_id == role.id)
.all()
)
pending_shift_entries = (
ShiftEntry.query.join(ShiftEntry.shift)
.filter(
Shift.start <= now - timedelta(minutes=15), Shift.role == role, ShiftEntry.state == "signed_up"
)
.all()
)

return render_template(
"volunteer/role_admin.html",
role=role,
shifts=shifts,
active_shift_entries=active_shift_entries,
pending_shift_entries=pending_shift_entries,
now=now,
offset=offset,
limit=limit,
)


@volunteer.route("role/<int:role_id>/toggle_arrived/<int:shift_id>/<int:user_id>")
@role_admin_required
def toggle_arrived(role_id, shift_id, user_id):
se = ShiftEntry.query.filter(
ShiftEntry.shift_id == shift_id, ShiftEntry.user_id == user_id
).first_or_404()
se.arrived = not se.arrived
db.session.commit()
return redirect(url_for(".role_admin", role_id=role_id))


@volunteer.route("role/<int:role_id>/toggle_abandoned/<int:shift_id>/<int:user_id>")
@volunteer.route("role/<int:role_id>/set_state/<int:shift_id>/<int:user_id>", methods=["POST"])
@role_admin_required
def toggle_abandoned(role_id, shift_id, user_id):
se = ShiftEntry.query.filter(
ShiftEntry.shift_id == shift_id, ShiftEntry.user_id == user_id
).first_or_404()
se.abandoned = not se.abandoned
db.session.commit()
return redirect(url_for(".role_admin", role_id=role_id))

def set_state(role_id: int, shift_id: int, user_id: int):
state = request.form["state"]

try:
se = ShiftEntry.query.filter(
ShiftEntry.shift_id == shift_id, ShiftEntry.user_id == user_id
).first_or_404()
if se.state != state:
se.set_state(state)
db.session.commit()
except ShiftEntryStateException:
flash(f"{state} is not a valid state for this shift.")

@volunteer.route("role/<int:role_id>/toggle_complete/<int:shift_id>/<int:user_id>")
@role_admin_required
def toggle_complete(role_id, shift_id, user_id):
se = ShiftEntry.query.filter(
ShiftEntry.shift_id == shift_id, ShiftEntry.user_id == user_id
).first_or_404()
se.completed = not se.completed
db.session.commit()
return redirect(url_for(".role_admin", role_id=role_id))


@volunteer.route("role/<int:role_id>/volunteers")
@role_admin_required
def role_volunteers(role_id):
role = Role.query.get_or_404(role_id)
entries = ShiftEntry.query.filter(ShiftEntry.shift.has(role_id=role_id)).all()
interested = VolunteerUser.query.join(VolunteerUser.interested_roles).filter(Role.id == role_id).all()
entries = ShiftEntry.query.join(ShiftEntry.shift).filter(Shift.role_id == role_id).all()
signed_up = list(set([se.user.volunteer for se in entries]))
completed = list(set([se.user.volunteer for se in entries if se.completed]))
completed = list(set([se.user.volunteer for se in entries if se.state == "completed"]))
return render_template(
"volunteer/role_volunteers.html",
role=role,
interested=interested,
signed_up=signed_up,
completed=completed,
)
8 changes: 2 additions & 6 deletions apps/volunteer/data/roles/cable_plugger.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
name: Cable Plugger
description: Plug/Unplug DKs
full_description_md: |
During the first days of EMF this role involves going round the DKs and plugging
in network and power cables, making sure they're safely positioned to avoid
people tripping, or damaging our equipment. In the later days you'll be
unplugging cables instead.
During the first days of EMF this role involves going round the DKs and plugging in network and power cables, making sure they're safely positioned to avoid people tripping, or damaging our equipment. In the later days you'll be unplugging cables instead.
You'll need to be reasonably mobile as this role requires a lot of walking
around the site.
You'll need to be reasonably mobile as this role requires a lot of walking around the site.
25 changes: 7 additions & 18 deletions apps/volunteer/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,7 @@ def schedule():
venues = VolunteerVenue.get_all()

untrained_roles = [
r
for r in roles
if r["is_interested"] and r["requires_training"] and not r["is_trained"]
r for r in roles if r["is_interested"] and r["requires_training"] and not r["is_trained"]
]

return render_template(
Expand All @@ -97,17 +95,15 @@ def shift(shift_id):
shift = Shift.query.get_or_404(shift_id)
all_volunteers = Volunteer.query.order_by(Volunteer.nickname).all()

return render_template(
"volunteer/shift.html", shift=shift, all_volunteers=all_volunteers
)
return render_template("volunteer/shift.html", shift=shift, all_volunteers=all_volunteers)


@volunteer.route("/shift/<shift_id>/sign-up", methods=["POST"])
@feature_flag("VOLUNTEERS_SCHEDULE")
@v_user_required
def shift_sign_up(shift_id):
shift = Shift.query.get_or_404(shift_id)
if current_user.has_permission("volunteer:admin") and request.form["user_id"]:
if current_user.has_permission("volunteer:admin") and "user_id" in request.form:
user = User.query.get(request.form["user_id"])
else:
user = current_user
Expand All @@ -119,17 +115,10 @@ def shift_sign_up(shift_id):
return redirect_next_or_schedule(f"Signed up for {shift.role.name} shift")

if shift.current_count >= shift.max_needed:
return redirect_next_or_schedule(
"This shift is already full. You have not been signed up."
)

if (
shift.role.requires_training
and shift.role not in Volunteer.get_for_user(current_user).trained_roles
):
return redirect_next_or_schedule(
"You must complete training before you can sign up for this shift."
)
return redirect_next_or_schedule("This shift is already full. You have not been signed up.")

if shift.role.requires_training and shift.role not in Volunteer.get_for_user(current_user).trained_roles:
return redirect_next_or_schedule("You must complete training before you can sign up for this shift.")

for shift_entry in user.shift_entries:
if shift.is_clash(shift_entry.shift):
Expand Down
19 changes: 6 additions & 13 deletions apps/volunteer/training.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
from wtforms import SubmitField, BooleanField, FormField, FieldList
from wtforms.validators import InputRequired

from apps.volunteer.choose_roles import role_admin_required
from main import db

from models.volunteer.role import Role
from models.volunteer.volunteer import Volunteer

from . import v_admin_required, volunteer
from . import volunteer
from ..common.forms import Form
from ..common.fields import HiddenIntegerField

Expand Down Expand Up @@ -39,21 +40,13 @@ def add_volunteers(self, volunteers):
field.label = field._volunteer.nickname


@volunteer.route("/train-users")
@v_admin_required
def select_training():
return render_template(
"volunteer/training/select_training.html", roles=Role.get_all()
)


@volunteer.route("/train-users/<role_id>", methods=["GET", "POST"])
@v_admin_required
@volunteer.route("/role-admin/<role_id>/train-users", methods=["GET", "POST"])
@role_admin_required
def train_users(role_id):
role = Role.get_by_id(role_id)
form = TrainingForm()

form.add_volunteers(Volunteer.get_all())
volunteers = Volunteer.query.join(Volunteer.interested_roles).filter(Role.id == role_id).all()
form.add_volunteers(volunteers)

if form.validate_on_submit():
changes = 0
Expand Down
4 changes: 4 additions & 0 deletions css/_base.scss
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ a:hover {
a {
color: $main-background-text;
}

p:last-child {
margin-bottom: 0;
}
}

.panel {
Expand Down
2 changes: 1 addition & 1 deletion css/_variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ $main-background: $brand-2024-dark-green;
$main-background-text: #d8d8d8;
$main-background-header: #ffffff;

$content-well-background: #292236;
$content-well-background: $brand-2024-mid-green;

$main-link: $main-background-text;
$main-link-hover: $brand-2024-blue;
Expand Down
19 changes: 19 additions & 0 deletions css/volunteer_schedule.scss
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,22 @@ table.shifts-table tbody td.mobile-only {
}
}
}

.role-admin-shift {
.volunteer {
p {
margin-bottom: 0;
}

margin-bottom: 15px;
}

.volunteer:last-child {
margin-bottom: 0;
}

.overtime {
font-weight: bold;
color: $brand-2024-pink;
}
}
10 changes: 9 additions & 1 deletion js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,12 @@ $(() => {
return false;
});
});
});
});

$(() => {
document.querySelectorAll('.role-state-selector').forEach((el) => {
el.addEventListener('change', (event) => {
event.target.form.submit();
})
});
});
10 changes: 9 additions & 1 deletion js/volunteer-schedule.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@ function saveFilters() {
function loadFilters() {
let savedFilters = localStorage.getItem("volunteer-filters:v2");
if (savedFilters === null) {
return
interestedRoles = document.querySelectorAll('input[data-role-id]').forEach((checkbox) => checkbox.checked = checkbox.getAttribute('data-interested') == 'True');
savedFilters = {
"role_ids": interestedRoles,
"show_finished_shifts": false,
"signed_up": false,
"hide_full": false,
"hide_staffed": false,
"colourful_mode": false,
}
}

let filters = JSON.parse(savedFilters)
Expand Down
Loading

0 comments on commit b140235

Please sign in to comment.