From c5f6b25e47e96e8b300bea2e928d37b7b8dc5ace Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Thu, 9 May 2024 22:17:51 +0000 Subject: [PATCH 01/13] Fix newlines on cable plugger --- apps/volunteer/data/roles/cable_plugger.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/volunteer/data/roles/cable_plugger.yml b/apps/volunteer/data/roles/cable_plugger.yml index 92859db29..bd2a256db 100644 --- a/apps/volunteer/data/roles/cable_plugger.yml +++ b/apps/volunteer/data/roles/cable_plugger.yml @@ -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. From 1394149bf3214c9435a6c00c25e26e30c8c1f955 Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Thu, 9 May 2024 22:19:29 +0000 Subject: [PATCH 02/13] Signup works from a user ID, not a volunteer ID --- templates/volunteer/shift.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/volunteer/shift.html b/templates/volunteer/shift.html index 9bb42133d..3dde807b2 100644 --- a/templates/volunteer/shift.html +++ b/templates/volunteer/shift.html @@ -28,7 +28,7 @@

Add volunteer

From 8e36359d8efa624be12f0d5c67fb8732d6377294 Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Thu, 9 May 2024 23:08:12 +0000 Subject: [PATCH 03/13] Switch shift entry states to a state machine --- apps/metrics.py | 19 ++------ apps/volunteer/choose_roles.py | 46 +++++++----------- ...aa_migrate_shift_entry_states_to_state_.py | 48 +++++++++++++++++++ models/volunteer/shift.py | 37 ++++++++++++-- models/volunteer/volunteer.py | 18 ++----- templates/volunteer/role_admin.html | 22 ++++----- 6 files changed, 114 insertions(+), 76 deletions(-) create mode 100644 migrations/versions/b9db98f438aa_migrate_shift_entry_states_to_state_.py diff --git a/apps/metrics.py b/apps/metrics.py index ea7307143..02a4288b5 100644 --- a/apps/metrics.py +++ b/apps/metrics.py @@ -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 = ( @@ -113,14 +105,9 @@ 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) ) diff --git a/apps/volunteer/choose_roles.py b/apps/volunteer/choose_roles.py index 271955159..057b81eb9 100644 --- a/apps/volunteer/choose_roles.py +++ b/apps/volunteer/choose_roles.py @@ -18,7 +18,12 @@ 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, + ShiftEntryState, + ShiftEntryStateException, +) from . import volunteer, v_user_required from ..common import feature_enabled, feature_flag @@ -189,36 +194,19 @@ def role_admin(role_id): ) -@volunteer.route("role//toggle_arrived//") -@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//toggle_abandoned//") +@volunteer.route("role//set_state///") @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 toggle_arrived(role_id: int, shift_id: int, user_id: int, state: ShiftEntryState): + 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//toggle_complete//") -@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)) diff --git a/migrations/versions/b9db98f438aa_migrate_shift_entry_states_to_state_.py b/migrations/versions/b9db98f438aa_migrate_shift_entry_states_to_state_.py new file mode 100644 index 000000000..7a52558a5 --- /dev/null +++ b/migrations/versions/b9db98f438aa_migrate_shift_entry_states_to_state_.py @@ -0,0 +1,48 @@ +"""migrate_shift_entry_states_to_state_field + +Revision ID: b9db98f438aa +Revises: e9529c62ca57 +Create Date: 2024-05-09 22:50:00.428018 + +""" + +# revision identifiers, used by Alembic. +revision = 'b9db98f438aa' +down_revision = 'e9529c62ca57' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('volunteer_shift_entry', sa.Column('state', sa.String(), nullable=True)) + op.drop_column('volunteer_shift_entry', 'completed') + op.drop_column('volunteer_shift_entry', 'arrived') + op.drop_column('volunteer_shift_entry', 'missing_others') + op.drop_column('volunteer_shift_entry', 'abandoned') + op.drop_column('volunteer_shift_entry', 'checked_in') + op.add_column('volunteer_shift_entry_version', sa.Column('state', sa.String(), autoincrement=False, nullable=True)) + op.drop_column('volunteer_shift_entry_version', 'completed') + op.drop_column('volunteer_shift_entry_version', 'arrived') + op.drop_column('volunteer_shift_entry_version', 'missing_others') + op.drop_column('volunteer_shift_entry_version', 'abandoned') + op.drop_column('volunteer_shift_entry_version', 'checked_in') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('volunteer_shift_entry_version', sa.Column('checked_in', sa.BOOLEAN(), autoincrement=False, nullable=True)) + op.add_column('volunteer_shift_entry_version', sa.Column('abandoned', sa.BOOLEAN(), autoincrement=False, nullable=True)) + op.add_column('volunteer_shift_entry_version', sa.Column('missing_others', sa.BOOLEAN(), autoincrement=False, nullable=True)) + op.add_column('volunteer_shift_entry_version', sa.Column('arrived', sa.BOOLEAN(), autoincrement=False, nullable=True)) + op.add_column('volunteer_shift_entry_version', sa.Column('completed', sa.BOOLEAN(), autoincrement=False, nullable=True)) + op.drop_column('volunteer_shift_entry_version', 'state') + op.add_column('volunteer_shift_entry', sa.Column('checked_in', sa.BOOLEAN(), autoincrement=False, nullable=False)) + op.add_column('volunteer_shift_entry', sa.Column('abandoned', sa.BOOLEAN(), autoincrement=False, nullable=True)) + op.add_column('volunteer_shift_entry', sa.Column('missing_others', sa.BOOLEAN(), autoincrement=False, nullable=False)) + op.add_column('volunteer_shift_entry', sa.Column('arrived', sa.BOOLEAN(), autoincrement=False, nullable=True)) + op.add_column('volunteer_shift_entry', sa.Column('completed', sa.BOOLEAN(), autoincrement=False, nullable=True)) + op.drop_column('volunteer_shift_entry', 'state') + # ### end Alembic commands ### diff --git a/models/volunteer/shift.py b/models/volunteer/shift.py index a792eea43..300a5e343 100644 --- a/models/volunteer/shift.py +++ b/models/volunteer/shift.py @@ -1,3 +1,4 @@ +from typing import Literal, TypeAlias, Union import pytz from pendulum import period @@ -10,21 +11,47 @@ event_tz = pytz.timezone("Europe/London") +# state: [allowed next state, ] pairs +ShiftEntryState: TypeAlias = Union[ + Literal["signed_up"], Literal["arrived"], Literal["abandoned"], Literal["completed"], Literal["no_show"] +] + +SHIFT_ENTRY_STATES: dict[ShiftEntryState, list[ShiftEntryState]] = { + "signed_up": ["arrived", "abandoned", "completed", "no_show"], + "arrived": ["abandoned", "completed", "signed_up"], + "abandoned": ["arrived"], + "completed": ["arrived"], + "no_show": ["arrived"], +} + + +class ShiftEntryStateException(ValueError): + """Raised when a shift entry is moved to an invalid state.""" + + class ShiftEntry(BaseModel): __tablename__ = "volunteer_shift_entry" __versioned__: dict = {} shift_id = db.Column(db.Integer, db.ForeignKey("volunteer_shift.id"), primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True) - checked_in = db.Column(db.Boolean, nullable=False, default=False) - missing_others = db.Column(db.Boolean, nullable=False, default=False) - arrived = db.Column(db.Boolean, default=False) - abandoned = db.Column(db.Boolean, default=False) - completed = db.Column(db.Boolean, default=False) + state: ShiftEntryState = db.Column(db.String, default="signed_up") user = db.relationship("User", backref="shift_entries") shift = db.relationship("Shift", backref="entries") + def set_state(self, state: ShiftEntryState): + if state not in SHIFT_ENTRY_STATES: + raise ShiftEntryStateException('"%s" is not a valid state' % state) + + if state not in SHIFT_ENTRY_STATES[self.state]: + raise ShiftEntryStateException('"%s->%s" is not a valid transition' % (self.state, state)) + + self.state = state + + def valid_states(self) -> list[ShiftEntryState]: + return SHIFT_ENTRY_STATES[self.state] + class Shift(BaseModel): __tablename__ = "volunteer_shift" diff --git a/models/volunteer/volunteer.py b/models/volunteer/volunteer.py index 69a666414..07d94fbd0 100644 --- a/models/volunteer/volunteer.py +++ b/models/volunteer/volunteer.py @@ -12,12 +12,8 @@ VolunteerRoleInterest = db.Table( "volunteer_role_interest", db.Model.metadata, - db.Column( - "volunteer_id", db.Integer, db.ForeignKey("volunteer.id"), primary_key=True - ), - db.Column( - "role_id", db.Integer, db.ForeignKey("volunteer_role.id"), primary_key=True - ), + db.Column("volunteer_id", db.Integer, db.ForeignKey("volunteer.id"), primary_key=True), + db.Column("role_id", db.Integer, db.ForeignKey("volunteer_role.id"), primary_key=True), ) @@ -25,12 +21,8 @@ VolunteerRoleTraining = db.Table( "volunteer_role_training", db.Model.metadata, - db.Column( - "volunteer_id", db.Integer, db.ForeignKey("volunteer.id"), primary_key=True - ), - db.Column( - "role_id", db.Integer, db.ForeignKey("volunteer_role.id"), primary_key=True - ), + db.Column("volunteer_id", db.Integer, db.ForeignKey("volunteer.id"), primary_key=True), + db.Column("role_id", db.Integer, db.ForeignKey("volunteer_role.id"), primary_key=True), ) @@ -72,7 +64,7 @@ def completed_shift(self, role): shifts = ShiftEntry.query.filter( ShiftEntry.shift.has(role=role), ShiftEntry.user == self.user, - ShiftEntry.completed, + ShiftEntry.state == "completed", ).all() return bool(shifts) diff --git a/templates/volunteer/role_admin.html b/templates/volunteer/role_admin.html index 918dadf1a..a05a17e52 100644 --- a/templates/volunteer/role_admin.html +++ b/templates/volunteer/role_admin.html @@ -12,19 +12,15 @@

{{ role }} Admin

{{ shift.venue.name }} {{ s['start'] }} - {{ s['end_time'] }}

{% for se in shift.entries|sort(attribute="user_id") %} -

{{ se.user.volunteer.nickname }} {# TODO: Add indication of already completed shift for this roe #} - {% if se.user.volunteer.completed_shift(role) %} - Previous completed shift: ✓ {% endif %} - - Arrived:{% if se.arrived %}✓{% else %}✗{% endif %} -   - - Abandoned:{% if se.abandoned %}✓{% else %}✗{% endif %} -   - - Complete:{% if se.completed %}✓{% else %}✗{% endif %} - -
{{ se.user.volunteer.volunteer_email }} - {% if se.user.volunteer.volunteer_phone %}Phone: {{ se.user.volunteer.volunteer_phone }}{% endif %} +

+ {{ se.user.volunteer.nickname }} + {{ se.user.volunteer.volunteer_email }} + {% if se.user.volunteer.volunteer_phone %}Phone: {{ se.user.volunteer.volunteer_phone }}{% endif %}
+ {% if not se.user.volunteer.completed_shift(role) %}First shift
{% endif %} + Mark as:  + {% for state in se.valid_states() %} + {{ state | capitalize | replace("_", " ") }} + {% endfor %}

{% endfor %}
From 313666b171dab008fc079a1f3c741fd4d3d1c541 Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Thu, 9 May 2024 23:09:03 +0000 Subject: [PATCH 04/13] Don't show volunteer email addresses in role admin I'm not convinced they're ever going to be used for anything, and I don't think it's obvious to volunteers that they're giving their email address to all role admins. If a role admin needs it they can either ask the volunteer themselves, or escalate to someone with access. --- templates/volunteer/role_admin.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/templates/volunteer/role_admin.html b/templates/volunteer/role_admin.html index a05a17e52..e12b8b5f7 100644 --- a/templates/volunteer/role_admin.html +++ b/templates/volunteer/role_admin.html @@ -14,8 +14,7 @@

{{ shift.venue.name }} {{ s['start'] }} - {{ s['end_time'] }}

{% for se in shift.entries|sort(attribute="user_id") %}

{{ se.user.volunteer.nickname }} - {{ se.user.volunteer.volunteer_email }} - {% if se.user.volunteer.volunteer_phone %}Phone: {{ se.user.volunteer.volunteer_phone }}{% endif %}
+ {% if se.user.volunteer.volunteer_phone %}({{ se.user.volunteer.volunteer_phone }}){% endif %}
{% if not se.user.volunteer.completed_shift(role) %}First shift
{% endif %} Mark as:  {% for state in se.valid_states() %} From 1c648b064383598f1fb0847be62319de7bc0f584 Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Thu, 9 May 2024 23:19:19 +0000 Subject: [PATCH 05/13] Tidy up role admin a bit --- templates/volunteer/role_admin.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/volunteer/role_admin.html b/templates/volunteer/role_admin.html index e12b8b5f7..7fcc395b8 100644 --- a/templates/volunteer/role_admin.html +++ b/templates/volunteer/role_admin.html @@ -10,13 +10,13 @@

{{ role }} Admin

{% for shift in shifts %} {% set s = shift.to_localtime_dict() %}
-

{{ shift.venue.name }} {{ s['start'] }} - {{ s['end_time'] }}

+

{{ shift.start.strftime("%a %H:%M") }} - {{ shift.end.strftime("%H:%M") }} - {{ shift.venue.name }}

{% for se in shift.entries|sort(attribute="user_id") %}

{{ se.user.volunteer.nickname }} {% if se.user.volunteer.volunteer_phone %}({{ se.user.volunteer.volunteer_phone }}){% endif %}
{% if not se.user.volunteer.completed_shift(role) %}First shift
{% endif %} - Mark as:  + {{ se.state | capitalize | replace("_", " ") }} | Mark as:  {% for state in se.valid_states() %} {{ state | capitalize | replace("_", " ") }} {% endfor %} From 3fcd5078ea03156a9250f7a218551bb7c2bcd5e7 Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Fri, 10 May 2024 23:17:18 +0000 Subject: [PATCH 06/13] Add active and due volunteers to role admin --- apps/volunteer/choose_roles.py | 32 ++++++++++++++-- css/_base.scss | 4 ++ css/_variables.scss | 2 +- css/volunteer_schedule.scss | 19 ++++++++++ js/main.js | 10 ++++- models/volunteer/shift.py | 4 +- templates/volunteer/role_admin.html | 59 +++++++++++++++++++++++------ 7 files changed, 112 insertions(+), 18 deletions(-) diff --git a/apps/volunteer/choose_roles.py b/apps/volunteer/choose_roles.py index 057b81eb9..d77203d95 100644 --- a/apps/volunteer/choose_roles.py +++ b/apps/volunteer/choose_roles.py @@ -173,10 +173,16 @@ def role_admin_required(f, *args, **kwargs): @volunteer.route("role//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) @@ -185,18 +191,38 @@ 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//set_state///") +@volunteer.route("role//set_state//", methods=["POST"]) @role_admin_required -def toggle_arrived(role_id: int, shift_id: int, user_id: int, state: ShiftEntryState): +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 diff --git a/css/_base.scss b/css/_base.scss index 5421e6f8b..61a83ef59 100644 --- a/css/_base.scss +++ b/css/_base.scss @@ -71,6 +71,10 @@ a:hover { a { color: $main-background-text; } + + p:last-child { + margin-bottom: 0; + } } .panel { diff --git a/css/_variables.scss b/css/_variables.scss index 9bf690811..805a328f7 100644 --- a/css/_variables.scss +++ b/css/_variables.scss @@ -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; diff --git a/css/volunteer_schedule.scss b/css/volunteer_schedule.scss index 0d35bd5c5..ede4868e2 100644 --- a/css/volunteer_schedule.scss +++ b/css/volunteer_schedule.scss @@ -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; + } +} diff --git a/js/main.js b/js/main.js index 96aff571f..bcc68d483 100644 --- a/js/main.js +++ b/js/main.js @@ -127,4 +127,12 @@ $(() => { return false; }); }); -}); \ No newline at end of file +}); + +$(() => { + document.querySelectorAll('.role-state-selector').forEach((el) => { + el.addEventListener('change', (event) => { + event.target.form.submit(); + }) + }); +}); diff --git a/models/volunteer/shift.py b/models/volunteer/shift.py index 300a5e343..33f70e292 100644 --- a/models/volunteer/shift.py +++ b/models/volunteer/shift.py @@ -17,8 +17,8 @@ ] SHIFT_ENTRY_STATES: dict[ShiftEntryState, list[ShiftEntryState]] = { - "signed_up": ["arrived", "abandoned", "completed", "no_show"], - "arrived": ["abandoned", "completed", "signed_up"], + "signed_up": ["arrived", "completed", "abandoned", "no_show"], + "arrived": ["completed", "abandoned", "signed_up"], "abandoned": ["arrived"], "completed": ["arrived"], "no_show": ["arrived"], diff --git a/templates/volunteer/role_admin.html b/templates/volunteer/role_admin.html index 7fcc395b8..43dd3886e 100644 --- a/templates/volunteer/role_admin.html +++ b/templates/volunteer/role_admin.html @@ -1,5 +1,18 @@ {% extends 'base.html' %} +{% macro state_selector(shift_entry) %} +

+ + +
+{% endmacro %} + {% block title %} EMF Volunteer Role Admin {% endblock %} @@ -7,20 +20,44 @@ {% block body %} {% include "volunteer/_nav.html" %}

{{ role }} Admin

+

Volunteers on Shift

+
+ {% for active in active_shift_entries %} +
+

+ {{ active.user.volunteer.nickname }} {{ active.shift.start.strftime("%a %H:%M") }}–{{ active.shift.end.strftime("%H:%M") }} + {% if now > active.shift.end %} +
{{ now - active.shift.end }} over + {% endif %} +

+ {{ state_selector(active) }} +
+ {% endfor %} +
+ +

Volunteers Due

+
+ {% for pending in pending_shift_entries %} +
+

{{ pending.user.volunteer.nickname }}: {{ pending.shift.start.strftime("%a %H:%M") }}–{{ pending.shift.end.strftime("%H:%M") }}

+ {{ state_selector(pending) }} +
+ {% endfor %} +
+ +

Upcoming Shifts

{% for shift in shifts %} - {% set s = shift.to_localtime_dict() %}
-

{{ shift.start.strftime("%a %H:%M") }} - {{ shift.end.strftime("%H:%M") }} - {{ shift.venue.name }}

+

{{ shift.start.strftime("%a %H:%M") }} - {{ shift.end.strftime("%H:%M") }} - {{ shift.venue.name }}

{% for se in shift.entries|sort(attribute="user_id") %} -

- {{ se.user.volunteer.nickname }} - {% if se.user.volunteer.volunteer_phone %}({{ se.user.volunteer.volunteer_phone }}){% endif %}
- {% if not se.user.volunteer.completed_shift(role) %}First shift
{% endif %} - {{ se.state | capitalize | replace("_", " ") }} | Mark as:  - {% for state in se.valid_states() %} - {{ state | capitalize | replace("_", " ") }} - {% endfor %} -

+
+

+ {{ se.user.volunteer.nickname }} + {% if se.user.volunteer.volunteer_phone %}({{ se.user.volunteer.volunteer_phone }}){% endif %}
+ {% if not se.user.volunteer.completed_shift(role) %}First shift
{% endif %} +

+ {{ state_selector(se) }} +
{% endfor %}
{% endfor %} From 9859fc1bddf1cb5437803c22d8a8784af5664341 Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Sat, 11 May 2024 11:54:29 +0000 Subject: [PATCH 07/13] Placate Ruff --- apps/volunteer/choose_roles.py | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/volunteer/choose_roles.py b/apps/volunteer/choose_roles.py index d77203d95..c244dd80b 100644 --- a/apps/volunteer/choose_roles.py +++ b/apps/volunteer/choose_roles.py @@ -21,7 +21,6 @@ from models.volunteer.shift import ( Shift, ShiftEntry, - ShiftEntryState, ShiftEntryStateException, ) From 0ff7e2e5757b3e7da98339489543618d5f785fee Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Sat, 11 May 2024 11:54:41 +0000 Subject: [PATCH 08/13] Fix the volunteer list page --- apps/volunteer/choose_roles.py | 6 ++++-- templates/volunteer/role_volunteers.html | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/volunteer/choose_roles.py b/apps/volunteer/choose_roles.py index c244dd80b..b042d52ae 100644 --- a/apps/volunteer/choose_roles.py +++ b/apps/volunteer/choose_roles.py @@ -239,12 +239,14 @@ def set_state(role_id: int, shift_id: int, user_id: int): @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, ) diff --git a/templates/volunteer/role_volunteers.html b/templates/volunteer/role_volunteers.html index 210d1a033..20246c37c 100644 --- a/templates/volunteer/role_volunteers.html +++ b/templates/volunteer/role_volunteers.html @@ -14,12 +14,15 @@ {% include "volunteer/_nav.html" %}

{{ role }} Volunteers

-

All volunteers signed up for a {{ role }} shift

+

Signed up for a shift - {{ signed_up | length }}

{{ listvols(signed_up) }} {% if completed %} -

Shift marked as complete

+

With one or more completed shifts - {{ completed | length }}

{{ listvols(completed) }} {% endif %} +

Interested Volunteers - {{ interested | length }}

+ {{ listvols(interested) }} + {% endblock %} From bfee0f48faccebd184370245385486f048ece663 Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Sat, 11 May 2024 12:58:02 +0000 Subject: [PATCH 09/13] Make volunteer training accessible to role admins --- apps/volunteer/training.py | 19 ++++++------------- templates/volunteer/role_admin.html | 4 +++- templates/volunteer/training/train_users.html | 1 + 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/apps/volunteer/training.py b/apps/volunteer/training.py index 18d772972..1fb93e9f7 100644 --- a/apps/volunteer/training.py +++ b/apps/volunteer/training.py @@ -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 @@ -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/", methods=["GET", "POST"]) -@v_admin_required +@volunteer.route("/role-admin//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 diff --git a/templates/volunteer/role_admin.html b/templates/volunteer/role_admin.html index 43dd3886e..cb377665c 100644 --- a/templates/volunteer/role_admin.html +++ b/templates/volunteer/role_admin.html @@ -69,5 +69,7 @@

{{ shift.start.strftime("%a %H:%M") }} - {{ shift.end.strftime("%H:%M") }} - Next

-

List of {{ role }} volunteers

+ {% endblock %} diff --git a/templates/volunteer/training/train_users.html b/templates/volunteer/training/train_users.html index de5007a2d..79e4eb259 100644 --- a/templates/volunteer/training/train_users.html +++ b/templates/volunteer/training/train_users.html @@ -4,6 +4,7 @@ Train users {% endblock %} {% block body %} + {% include "volunteer/_nav.html" %}

Who have you trained?

{{ form.hidden_tag() }} From 20baf086225d95492fd50700dd6b80b543ee5cab Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Sat, 11 May 2024 13:00:47 +0000 Subject: [PATCH 10/13] Duplicate volunteer training links at top of page --- templates/volunteer/role_admin.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/templates/volunteer/role_admin.html b/templates/volunteer/role_admin.html index cb377665c..1adc18f9c 100644 --- a/templates/volunteer/role_admin.html +++ b/templates/volunteer/role_admin.html @@ -20,6 +20,9 @@ {% block body %} {% include "volunteer/_nav.html" %}

{{ role }} Admin

+

Volunteers on Shift

{% for active in active_shift_entries %} From fdb7ab126e8d97dbccca29ed03dff0f7ed9f92f1 Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Sun, 12 May 2024 11:15:14 +0000 Subject: [PATCH 11/13] Add min/max required to role metrics --- apps/metrics.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/metrics.py b/apps/metrics.py index 02a4288b5..6feaf7bd5 100644 --- a/apps/metrics.py +++ b/apps/metrics.py @@ -114,6 +114,24 @@ def collect(self): 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, From 7e0313ba0f5b296e79a8301440e3c5b1374bb402 Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Mon, 13 May 2024 21:06:35 +0000 Subject: [PATCH 12/13] Apply default filters if none are stored --- js/volunteer-schedule.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/js/volunteer-schedule.js b/js/volunteer-schedule.js index 84d92339b..a05143986 100644 --- a/js/volunteer-schedule.js +++ b/js/volunteer-schedule.js @@ -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) From 7bd71879d1b95dab9f1393f814cb79a58abb3a16 Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Mon, 13 May 2024 21:22:01 +0000 Subject: [PATCH 13/13] Give volunteer admins the same shift signup UI It was very clunky for a volunteer admin to sign up to shifts for themselves, because the button to do so was replaced with a details button. That's now a link from the start time. Also, an unintended formatting pass on schedule.py. --- apps/volunteer/schedule.py | 25 +++++------------- templates/volunteer/schedule.html | 44 ++++++++++++++++++------------- 2 files changed, 33 insertions(+), 36 deletions(-) diff --git a/apps/volunteer/schedule.py b/apps/volunteer/schedule.py index 4dfa8ac0f..28a1e2a1c 100644 --- a/apps/volunteer/schedule.py +++ b/apps/volunteer/schedule.py @@ -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( @@ -97,9 +95,7 @@ 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//sign-up", methods=["POST"]) @@ -107,7 +103,7 @@ def shift(shift_id): @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 @@ -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): diff --git a/templates/volunteer/schedule.html b/templates/volunteer/schedule.html index 4249e4061..ad70282e6 100644 --- a/templates/volunteer/schedule.html +++ b/templates/volunteer/schedule.html @@ -73,27 +73,35 @@

Pick your shifts

{% else %} {{ hour }} {% endif %} - {{ shift.end_time if shift.end else '--' }} - {{ hour }}{% if shift.end %} to {{ shift.end_time }}{% endif %} + + {% if current_user.has_permission("volunteer:admin") %} + {{ shift.end_time if shift.end else '--' }} + {% else %} + {{ shift.end_time if shift.end else '--' }} + {% endif %} + + + {% if current_user.has_permission("volunteer:admin") %} + {{ hour }}{% if shift.end %} to {{ shift.end_time }}{% endif %} + {% else %} + {{ hour }}{% if shift.end %} to {{ shift.end_time }}{% endif %} + {% endif %} + {{ shift.venue.name }} {{ shift.role.name }} {{ shift.current_count }}/{{ shift.max_needed }} - {% if current_user.has_permission("volunteer:admin") %} - Details - {% else %} - - {%- if shift.is_user_shift -%} - - - - {%- else -%} -
- -
- {%- endif -%} - - - {% endif %} + + {%- if shift.is_user_shift -%} +
+ +
+ {%- else -%} +
+ +
+ {%- endif -%} + + {% endfor %} {% endfor %}