Skip to content

Commit b140235

Browse files
authored
Merge pull request #1525 from emfcamp/shift-management
Shift management
2 parents 179252c + 7bd7187 commit b140235

18 files changed

+297
-144
lines changed

apps/metrics.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -96,15 +96,7 @@ def collect(self):
9696
)
9797

9898
gauge_groups(
99-
emf_shifts,
100-
ShiftEntry.query.join(ShiftEntry.shift).join(Shift.role),
101-
Role.name,
102-
case(
103-
(ShiftEntry.completed, "completed"),
104-
(ShiftEntry.abandoned, "abandoned"),
105-
(ShiftEntry.arrived, "arrived"),
106-
else_="signed_up",
107-
),
99+
emf_shifts, ShiftEntry.query.join(ShiftEntry.shift).join(Shift.role), Role.name, ShiftEntry.state
108100
)
109101

110102
shift_seconds = (
@@ -113,20 +105,33 @@ def collect(self):
113105
.with_entities(
114106
func.sum(Shift.duration).label("minimum"),
115107
Role.name,
116-
case(
117-
(ShiftEntry.completed, "completed"),
118-
(ShiftEntry.abandoned, "abandoned"),
119-
(ShiftEntry.arrived, "arrived"),
120-
else_="signed_up",
121-
),
108+
ShiftEntry.state,
122109
)
123-
.group_by(Role.name, ShiftEntry.completed, ShiftEntry.abandoned, ShiftEntry.arrived)
110+
.group_by(Role.name, ShiftEntry.state)
124111
.order_by(Role.name)
125112
)
126113

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

117+
required_shift_seconds = (
118+
Shift.query.join(Shift.role)
119+
.with_entities(
120+
func.sum(Shift.duration * Shift.min_needed).label("minimum_secs"),
121+
func.sum(Shift.duration * Shift.max_needed).label("maximum_secs"),
122+
func.sum(Shift.min_needed).label("minimum"),
123+
func.sum(Shift.max_needed).label("maximum"),
124+
Role.name,
125+
)
126+
.group_by(Role.name)
127+
.order_by(Role.name)
128+
)
129+
for min_sec, max_sec, min, max, role in required_shift_seconds:
130+
emf_shift_seconds.add_metric([role, "min_required"], min_sec.total_seconds())
131+
emf_shift_seconds.add_metric([role, "max_required"], max_sec.total_seconds())
132+
emf_shifts.add_metric([role, "min_required"], min)
133+
emf_shifts.add_metric([role, "max_required"], max)
134+
130135
return [
131136
emf_purchases,
132137
emf_payments,

apps/volunteer/choose_roles.py

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
from main import db
1919
from models.volunteer.role import Role
2020
from models.volunteer.volunteer import Volunteer as VolunteerUser
21-
from models.volunteer.shift import Shift, ShiftEntry
21+
from models.volunteer.shift import (
22+
Shift,
23+
ShiftEntry,
24+
ShiftEntryStateException,
25+
)
2226

2327
from . import volunteer, v_user_required
2428
from ..common import feature_enabled, feature_flag
@@ -168,10 +172,16 @@ def role_admin_required(f, *args, **kwargs):
168172
@volunteer.route("role/<int:role_id>/admin")
169173
@role_admin_required
170174
def role_admin(role_id):
175+
# Allow mocking the time for testing.
176+
if "now" in request.args:
177+
now = datetime.strptime(request.args["now"], "%Y-%m-%dT%H:%M")
178+
else:
179+
now = datetime.now()
180+
171181
limit = int(request.args.get("limit", "5"))
172182
offset = int(request.args.get("offset", "0"))
173183
role = Role.query.get_or_404(role_id)
174-
cutoff = datetime.now() - timedelta(minutes=30)
184+
cutoff = now - timedelta(minutes=30)
175185
shifts = (
176186
Shift.query.filter_by(role=role)
177187
.filter(Shift.end >= cutoff)
@@ -180,58 +190,63 @@ def role_admin(role_id):
180190
.limit(limit)
181191
.all()
182192
)
193+
194+
active_shift_entries = (
195+
ShiftEntry.query.filter(ShiftEntry.state == "arrived")
196+
.join(ShiftEntry.shift)
197+
.filter(Shift.role_id == role.id)
198+
.all()
199+
)
200+
pending_shift_entries = (
201+
ShiftEntry.query.join(ShiftEntry.shift)
202+
.filter(
203+
Shift.start <= now - timedelta(minutes=15), Shift.role == role, ShiftEntry.state == "signed_up"
204+
)
205+
.all()
206+
)
207+
183208
return render_template(
184209
"volunteer/role_admin.html",
185210
role=role,
186211
shifts=shifts,
212+
active_shift_entries=active_shift_entries,
213+
pending_shift_entries=pending_shift_entries,
214+
now=now,
187215
offset=offset,
188216
limit=limit,
189217
)
190218

191219

192-
@volunteer.route("role/<int:role_id>/toggle_arrived/<int:shift_id>/<int:user_id>")
193-
@role_admin_required
194-
def toggle_arrived(role_id, shift_id, user_id):
195-
se = ShiftEntry.query.filter(
196-
ShiftEntry.shift_id == shift_id, ShiftEntry.user_id == user_id
197-
).first_or_404()
198-
se.arrived = not se.arrived
199-
db.session.commit()
200-
return redirect(url_for(".role_admin", role_id=role_id))
201-
202-
203-
@volunteer.route("role/<int:role_id>/toggle_abandoned/<int:shift_id>/<int:user_id>")
220+
@volunteer.route("role/<int:role_id>/set_state/<int:shift_id>/<int:user_id>", methods=["POST"])
204221
@role_admin_required
205-
def toggle_abandoned(role_id, shift_id, user_id):
206-
se = ShiftEntry.query.filter(
207-
ShiftEntry.shift_id == shift_id, ShiftEntry.user_id == user_id
208-
).first_or_404()
209-
se.abandoned = not se.abandoned
210-
db.session.commit()
211-
return redirect(url_for(".role_admin", role_id=role_id))
212-
222+
def set_state(role_id: int, shift_id: int, user_id: int):
223+
state = request.form["state"]
224+
225+
try:
226+
se = ShiftEntry.query.filter(
227+
ShiftEntry.shift_id == shift_id, ShiftEntry.user_id == user_id
228+
).first_or_404()
229+
if se.state != state:
230+
se.set_state(state)
231+
db.session.commit()
232+
except ShiftEntryStateException:
233+
flash(f"{state} is not a valid state for this shift.")
213234

214-
@volunteer.route("role/<int:role_id>/toggle_complete/<int:shift_id>/<int:user_id>")
215-
@role_admin_required
216-
def toggle_complete(role_id, shift_id, user_id):
217-
se = ShiftEntry.query.filter(
218-
ShiftEntry.shift_id == shift_id, ShiftEntry.user_id == user_id
219-
).first_or_404()
220-
se.completed = not se.completed
221-
db.session.commit()
222235
return redirect(url_for(".role_admin", role_id=role_id))
223236

224237

225238
@volunteer.route("role/<int:role_id>/volunteers")
226239
@role_admin_required
227240
def role_volunteers(role_id):
228241
role = Role.query.get_or_404(role_id)
229-
entries = ShiftEntry.query.filter(ShiftEntry.shift.has(role_id=role_id)).all()
242+
interested = VolunteerUser.query.join(VolunteerUser.interested_roles).filter(Role.id == role_id).all()
243+
entries = ShiftEntry.query.join(ShiftEntry.shift).filter(Shift.role_id == role_id).all()
230244
signed_up = list(set([se.user.volunteer for se in entries]))
231-
completed = list(set([se.user.volunteer for se in entries if se.completed]))
245+
completed = list(set([se.user.volunteer for se in entries if se.state == "completed"]))
232246
return render_template(
233247
"volunteer/role_volunteers.html",
234248
role=role,
249+
interested=interested,
235250
signed_up=signed_up,
236251
completed=completed,
237252
)
Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
name: Cable Plugger
22
description: Plug/Unplug DKs
33
full_description_md: |
4-
During the first days of EMF this role involves going round the DKs and plugging
5-
in network and power cables, making sure they're safely positioned to avoid
6-
people tripping, or damaging our equipment. In the later days you'll be
7-
unplugging cables instead.
4+
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.
85
9-
You'll need to be reasonably mobile as this role requires a lot of walking
10-
around the site.
6+
You'll need to be reasonably mobile as this role requires a lot of walking around the site.

apps/volunteer/schedule.py

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,7 @@ def schedule():
7575
venues = VolunteerVenue.get_all()
7676

7777
untrained_roles = [
78-
r
79-
for r in roles
80-
if r["is_interested"] and r["requires_training"] and not r["is_trained"]
78+
r for r in roles if r["is_interested"] and r["requires_training"] and not r["is_trained"]
8179
]
8280

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

100-
return render_template(
101-
"volunteer/shift.html", shift=shift, all_volunteers=all_volunteers
102-
)
98+
return render_template("volunteer/shift.html", shift=shift, all_volunteers=all_volunteers)
10399

104100

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

121117
if shift.current_count >= shift.max_needed:
122-
return redirect_next_or_schedule(
123-
"This shift is already full. You have not been signed up."
124-
)
125-
126-
if (
127-
shift.role.requires_training
128-
and shift.role not in Volunteer.get_for_user(current_user).trained_roles
129-
):
130-
return redirect_next_or_schedule(
131-
"You must complete training before you can sign up for this shift."
132-
)
118+
return redirect_next_or_schedule("This shift is already full. You have not been signed up.")
119+
120+
if shift.role.requires_training and shift.role not in Volunteer.get_for_user(current_user).trained_roles:
121+
return redirect_next_or_schedule("You must complete training before you can sign up for this shift.")
133122

134123
for shift_entry in user.shift_entries:
135124
if shift.is_clash(shift_entry.shift):

apps/volunteer/training.py

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
from wtforms import SubmitField, BooleanField, FormField, FieldList
66
from wtforms.validators import InputRequired
77

8+
from apps.volunteer.choose_roles import role_admin_required
89
from main import db
910

1011
from models.volunteer.role import Role
1112
from models.volunteer.volunteer import Volunteer
1213

13-
from . import v_admin_required, volunteer
14+
from . import volunteer
1415
from ..common.forms import Form
1516
from ..common.fields import HiddenIntegerField
1617

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

4142

42-
@volunteer.route("/train-users")
43-
@v_admin_required
44-
def select_training():
45-
return render_template(
46-
"volunteer/training/select_training.html", roles=Role.get_all()
47-
)
48-
49-
50-
@volunteer.route("/train-users/<role_id>", methods=["GET", "POST"])
51-
@v_admin_required
43+
@volunteer.route("/role-admin/<role_id>/train-users", methods=["GET", "POST"])
44+
@role_admin_required
5245
def train_users(role_id):
5346
role = Role.get_by_id(role_id)
5447
form = TrainingForm()
55-
56-
form.add_volunteers(Volunteer.get_all())
48+
volunteers = Volunteer.query.join(Volunteer.interested_roles).filter(Role.id == role_id).all()
49+
form.add_volunteers(volunteers)
5750

5851
if form.validate_on_submit():
5952
changes = 0

css/_base.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ a:hover {
7171
a {
7272
color: $main-background-text;
7373
}
74+
75+
p:last-child {
76+
margin-bottom: 0;
77+
}
7478
}
7579

7680
.panel {

css/_variables.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ $main-background: $brand-2024-dark-green;
2727
$main-background-text: #d8d8d8;
2828
$main-background-header: #ffffff;
2929

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

3232
$main-link: $main-background-text;
3333
$main-link-hover: $brand-2024-blue;

css/volunteer_schedule.scss

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,22 @@ table.shifts-table tbody td.mobile-only {
114114
}
115115
}
116116
}
117+
118+
.role-admin-shift {
119+
.volunteer {
120+
p {
121+
margin-bottom: 0;
122+
}
123+
124+
margin-bottom: 15px;
125+
}
126+
127+
.volunteer:last-child {
128+
margin-bottom: 0;
129+
}
130+
131+
.overtime {
132+
font-weight: bold;
133+
color: $brand-2024-pink;
134+
}
135+
}

js/main.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,12 @@ $(() => {
127127
return false;
128128
});
129129
});
130-
});
130+
});
131+
132+
$(() => {
133+
document.querySelectorAll('.role-state-selector').forEach((el) => {
134+
el.addEventListener('change', (event) => {
135+
event.target.form.submit();
136+
})
137+
});
138+
});

js/volunteer-schedule.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,15 @@ function saveFilters() {
77
function loadFilters() {
88
let savedFilters = localStorage.getItem("volunteer-filters:v2");
99
if (savedFilters === null) {
10-
return
10+
interestedRoles = document.querySelectorAll('input[data-role-id]').forEach((checkbox) => checkbox.checked = checkbox.getAttribute('data-interested') == 'True');
11+
savedFilters = {
12+
"role_ids": interestedRoles,
13+
"show_finished_shifts": false,
14+
"signed_up": false,
15+
"hide_full": false,
16+
"hide_staffed": false,
17+
"colourful_mode": false,
18+
}
1119
}
1220

1321
let filters = JSON.parse(savedFilters)

0 commit comments

Comments
 (0)