diff --git a/apps/volunteer/__init__.py b/apps/volunteer/__init__.py index 3fdfad19d..ecec9d7b0 100644 --- a/apps/volunteer/__init__.py +++ b/apps/volunteer/__init__.py @@ -37,6 +37,7 @@ def volunteer_variables(): from . import ( api, # noqa: F401 bar_training, # noqa: F401 + buildup, # noqa: F401 choose_roles, # noqa: F401 main, # noqa: F401 schedule, # noqa: F401 diff --git a/apps/volunteer/admin/__init__.py b/apps/volunteer/admin/__init__.py index 9f7a1d4c5..a1be336fd 100644 --- a/apps/volunteer/admin/__init__.py +++ b/apps/volunteer/admin/__init__.py @@ -13,6 +13,7 @@ from . import ( + buildup, # noqa: F401 role, # noqa: F401 shift, # noqa: F401 venue, # noqa: F401 diff --git a/apps/volunteer/admin/buildup.py b/apps/volunteer/admin/buildup.py new file mode 100644 index 000000000..f13eb121b --- /dev/null +++ b/apps/volunteer/admin/buildup.py @@ -0,0 +1,115 @@ +from typing import ClassVar + +from dateutil.rrule import DAILY, rrule +from flask_admin import expose + +from main import db +from models.volunteer.buildup import ( + BuildupSignupKey, + BuildupVolunteer, + buildup_end, + buildup_start, + teardown_end, + teardown_start, +) + +from ..flask_admin_base import VolunteerBaseView, VolunteerModelView +from . import volunteer_admin + + +class BuildupSignupKeyModelView(VolunteerModelView): + column_filters: ClassVar[list[str]] = ["team_name"] + form_columns: ClassVar[list[str]] = ["token", "team_name"] + + def edit_form(self, obj=None): + form = super().edit_form(obj=obj) + if not form.token.render_kw: + form.token.render_kw = {} + form.token.render_kw["readonly"] = True + return form + + +volunteer_admin.add_view( + BuildupSignupKeyModelView(BuildupSignupKey, db.session, name="Signup keys", category="Buildup") +) + + +class BuildupVolunteerModelView(VolunteerModelView): + form_excluded_columns: ClassVar[list[str]] = ["versions"] + + def _modify_widget_args(self, form, obj=None, create=False): + if not form.arrival_date.render_kw: + form.arrival_date.render_kw = {} + form.arrival_date.render_kw.update( + **{ + "data-min-date": buildup_start(), + "data-max-date": teardown_end(), + } + ) + if create: + form.arrival_date.render_kw["data-start-date"] = buildup_end() + + if not form.departure_date.render_kw: + form.departure_date.render_kw = {} + form.departure_date.render_kw.update( + **{ + "data-min-date": buildup_start(), + "data-max-date": teardown_end(), + } + ) + if create: + form.departure_date.render_kw["data-start-date"] = teardown_start() + + return form + + def create_form(self, obj=None): + return self._modify_widget_args(super().create_form(obj), create=True) + + def edit_form(self, obj=None): + return self._modify_widget_args(super().edit_form(obj)) + + +volunteer_admin.add_view( + BuildupVolunteerModelView(BuildupVolunteer, db.session, name="Volunteers", category="Buildup") +) + + +class BuildupVolunteerBreakdownView(VolunteerBaseView): + @expose("/") + def index(self): + days_data = [] + is_just_after_event = False + for dt in rrule(DAILY, dtstart=buildup_start(), until=teardown_end(), byhour=[6, 18]): + if buildup_end() <= dt <= teardown_start(): + is_just_after_event = True + continue + predicted_volunteers = BuildupVolunteer.query.filter( + (BuildupVolunteer.arrival_date <= dt) & (BuildupVolunteer.departure_date >= dt) + ) + arrived_volunteers = BuildupVolunteer.query.filter(BuildupVolunteer.recorded_on_site <= dt) + date_str = dt.date().strftime("%a %d-%b") + am_or_pm = "AM" if dt.time().hour < 12 else "PM" + days_data.append( + { + "date_str": date_str, + "am_or_pm": am_or_pm, + "is_just_after_event": is_just_after_event, + "predicted_onsite": predicted_volunteers.count(), + "arrived_onsite": arrived_volunteers.count(), + } + ) + if is_just_after_event: + is_just_after_event = False + max_predicted = max(d["predicted_onsite"] for d in days_data) + max_arrived = max(d["arrived_onsite"] for d in days_data) + return self.render( + "volunteer/admin/buildup_breakdown.html", + days_data=days_data, + max_predicted=max_predicted, + max_arrived=max_arrived, + ) + + +volunteer_admin.add_view( + BuildupVolunteerBreakdownView(name="Arrival breakdown", category="Buildup", endpoint="breakdown") +) diff --git a/apps/volunteer/buildup.py b/apps/volunteer/buildup.py new file mode 100644 index 000000000..2f5a20702 --- /dev/null +++ b/apps/volunteer/buildup.py @@ -0,0 +1,211 @@ +from datetime import datetime + +from flask import ( + abort, + flash, + redirect, + render_template, + request, + url_for, +) +from flask import ( + current_app as app, +) +from flask_login import current_user, login_required +from sqlalchemy import select +from wtforms import BooleanField, DateTimeLocalField, StringField, SubmitField +from wtforms.validators import DataRequired, InputRequired + +from apps.users import get_next_url +from main import db +from models.volunteer.buildup import BuildupSignupKey, BuildupVolunteer, buildup_start, teardown_end +from models.volunteer.volunteer import Volunteer as VolunteerUser + +from ..common import create_current_user +from ..common.forms import Form +from . import volunteer +from .sign_up import VolunteerSignUpForm, update_volunteer_from_form + + +class BuildupSignUpForm(Form): + arrival_date = DateTimeLocalField( + "Arrival Date", + [ + InputRequired(), + ], + format="%Y-%m-%dT%H:%M", + ) + departure_date = DateTimeLocalField( + "Departure Date", + [ + InputRequired(), + ], + format="%Y-%m-%dT%H:%M", + ) + + emergency_contact = StringField( + "Emergency Contact", + [ + InputRequired(), + ], + ) + + health_and_safety_briefing = BooleanField( + "I have read and agree to follow the rules in the Safety on Site briefing above", + [ + DataRequired(), + ], + ) + + save = SubmitField("Confirm buildup/teardown attendance") + + def validate(self, *args, **kwargs): + rv = super().validate(*args, **kwargs) + if not rv: + return False + + if self.arrival_date.data > self.departure_date.data: + self.departure_date.errors.append( + "You must depart after you arrive. Violating the laws of causality is not permitted." + ) + return False + return True + + +def update_buildup_volunteer_from_form(buv: BuildupVolunteer, form: BuildupSignUpForm) -> BuildupVolunteer: + if form.arrival_date.data: + buv.arrival_date = form.arrival_date.data + if form.departure_date.data: + buv.departure_date = form.departure_date.data + if form.emergency_contact.data: + buv.emergency_contact = form.emergency_contact.data + if form.health_and_safety_briefing.data: + buv.acked_health_and_safety_briefing_at = datetime.now() + return buv + + +def _buildup_register(show_form_on_success: bool = False, key: BuildupSignupKey | None = None): + volunteer = current_user.is_authenticated and VolunteerUser.get_for_user(current_user) + passed_validation = request.method == "POST" + volunteer_form = VolunteerSignUpForm(prefix="v", obj=volunteer) + if not volunteer and request.method == "GET" and current_user.is_authenticated: + volunteer_form.volunteer_email.data = current_user.email + volunteer_form.nickname.data = current_user.name + + volunteer_form.over_18.validators = [ + DataRequired(message="Sorry, but you must be over 18 to be on the field during buildup and teardown") + ] + if volunteer_form.validate_on_submit(): + if volunteer: + volunteer = update_volunteer_from_form(volunteer, volunteer_form) + db.session.add(volunteer) + else: + if not volunteer_form.volunteer_email.data or not volunteer_form.nickname.data: + abort(400) + if current_user.is_anonymous: + create_current_user(volunteer_form.volunteer_email.data, volunteer_form.nickname.data) + new_volunteer = VolunteerUser() + new_volunteer.user_id = current_user.id + new_volunteer = update_volunteer_from_form(new_volunteer, volunteer_form) + db.session.add(new_volunteer) + volunteer = new_volunteer + + current_user.grant_permission("volunteer:user") + else: + passed_validation = False + + # Now the buildup form + buv = current_user.is_authenticated and BuildupVolunteer.get_for_user(current_user) + buildup_form = BuildupSignUpForm(prefix="b", obj=buv) + if buv and buv.acked_health_and_safety_briefing_at: + buildup_form.health_and_safety_briefing.data = True + if buildup_form.validate_on_submit(): + buv = current_user.is_authenticated and BuildupVolunteer.get_for_user(current_user) + if buv: + buv = update_buildup_volunteer_from_form(buv, buildup_form) + db.session.add(buv) + else: + new_buv = BuildupVolunteer() + new_buv.user_id = current_user.id + if key: + new_buv.team_name = key.team_name + new_buv = update_buildup_volunteer_from_form(new_buv, buildup_form) + db.session.add(new_buv) + + current_user.grant_permission("volunteer:buildup") + else: + passed_validation = False + + if passed_validation: + db.session.commit() + app.logger.info("Added new volunteer user %s through buildup flow", volunteer) + flash("Thanks! Your buildup registration has been recorded.", "message") + + if not show_form_on_success: + return None + else: + db.session.rollback() + + return render_template( + "volunteer/buildup-sign-up.html", + user=current_user, + volunteer=volunteer, + volunteer_form=volunteer_form, + buildup_form=buildup_form, + buildup_start=buildup_start(), + teardown_end=teardown_end(), + ) + + +@volunteer.route("buildup/arrived/", methods=["GET", "POST"]) +@login_required +def buildup_arrived(secret): + if secret != app.config.get("BUILDUP_SECRET"): + abort(404) + + buv = BuildupVolunteer.get_for_user(current_user) + if not buv: + if response := _buildup_register(): + return response + # OK, they're registered now. + buv = BuildupVolunteer.get_for_user(current_user) + + if not buv.recorded_on_site: + buv.recorded_on_site = datetime.now() + db.session.add(buv) + db.session.commit() + + return render_template( + "volunteer/buildup-arrived.html", + user=current_user, + is_buildup=datetime.now() < buildup_start(), + buildup_signal_group=app.config.get("BUILDUP_SIGNAL_GROUP"), + ) + + +@volunteer.route("buildup", methods=["GET", "POST"]) +@login_required +def buildup_amend(): + buv = BuildupVolunteer.get_for_user(current_user) + if not buv and not current_user.has_permission("volunteer:buildup"): + return abort(404) + + return _buildup_register(show_form_on_success=True) + + +@volunteer.route("buildup/register/", methods=["GET", "POST"]) +def buildup_register(token: str): + key = db.session.execute( + select(BuildupSignupKey).where(BuildupSignupKey.token == token) + ).scalar_one_or_none() + if not key: + abort(404) + + if current_user.is_authenticated and BuildupVolunteer.get_for_user(current_user): + return redirect(url_for(".buildup_amend")) + + if response := _buildup_register(key=key): + return response + + # They completed registration: + return redirect(get_next_url(default=url_for(".buildup_amend"))) diff --git a/apps/volunteer/sign_up.py b/apps/volunteer/sign_up.py index 20a9b1160..1703c1959 100644 --- a/apps/volunteer/sign_up.py +++ b/apps/volunteer/sign_up.py @@ -11,7 +11,9 @@ ) from flask_login import current_user from markupsafe import Markup -from wtforms import BooleanField, StringField, SubmitField +from wtforms import BooleanField, Field, StringField, SubmitField +from wtforms.form import BaseForm +from wtforms.utils import unset_value from wtforms.validators import DataRequired, Email, ValidationError from apps.users import get_next_url @@ -24,15 +26,118 @@ from ..common.forms import Form from . import v_user_required, volunteer +# 14 regulated allergens: https://www.food.gov.uk/safety-hygiene/food-allergy-and-intolerance +ALLERGEN_CHOICES = [ + ("celery", "Celery"), + ("gluten", "Cereals containing gluten"), + ("crustaceans", "Crustaceans (inc. crabs, lobster, prawns)"), + ("eggs", "Eggs"), + ("fish", "Fish"), + ("lupin", "Lupin"), + ("milk", "Milk"), + ("molluscs", "Molluscs (inc. mussels, land snails, squid, oyster sauce)"), + ("mustard", "Mustard"), + ("tree_nuts", "Tree nuts (inc. cashews, almonds, hazelnuts)"), + ("peanuts", "Peanuts"), + ("sesame", "Sesame seeds"), + ("soya", "Soya"), + ("sulphites", "Sulphur dioxide/sulphites"), +] + +DIETARY_RESTRICTIONS_CHOICES = [ + ("vegan", "Vegan (plant based)"), + ("vegetarian", "Vegetarian (no meat or fish"), +] + + +class MultipleChoiceAndOtherWidget: + def __call__(self, field, **kwargs): + html = ["") + return Markup("".join(html)) + + +class MultipleChoiceAndOtherField(Field): + widget = MultipleChoiceAndOtherWidget() + + def __init__(self, label, choices, **kwargs): + super().__init__(label, **kwargs) + self._choices = choices + self._fields = {key: BooleanField(label) for key, label in choices} + self._fields["other"] = StringField("Other") + self._form = None + + def process(self, formdata, data=unset_value, extra_filters=None): + prefix = f"{self.name}-" + self._form = BaseForm(self._fields, prefix=prefix) + kwargs = {} + if data is not unset_value: + downstream_data = {} + for key in data: + downstream_data[key] = True + kwargs["data"] = downstream_data + self._form.process(formdata=formdata, **kwargs) + + def validate(self, form, extra_validators=tuple()): + return self._form.validate() + + def populate_obj(self, obj, name): + setattr(obj, name, self.data) + setattr(obj, f"{name}_other", self.data_other) + + def __iter__(self): + return iter(self._form) + + def __getitem__(self, name): + return self._form[name] + + def __getattr__(self, name): + return getattr(self._form, name) + + @property + def data(self): + selected_choices = set() + for choice_key, _ in self._choices: + if self._form[choice_key].data: + selected_choices.add(choice_key) + return selected_choices + + @property + def data_other(self): + return self._form["other"].data + + @data_other.setter + def data_other(self, value): + self._form["other"].data = value + + @property + def errors(self): + return self._form.errors + class VolunteerSignUpForm(Form): nickname = StringField("Name", [DataRequired()]) volunteer_email = StringField("Email", [Email(), DataRequired()]) over_18 = BooleanField("I'm at least 18 years old") volunteer_phone = TelField("Phone", min_length=3) + allergies = MultipleChoiceAndOtherField( + "Allergies", ALLERGEN_CHOICES, description="Anything which will cause health issues if ingested" + ) + dietary_restrictions = MultipleChoiceAndOtherField("Dietary Restrictions", DIETARY_RESTRICTIONS_CHOICES) sign_up = SubmitField("Sign Up") save = SubmitField("Save") + def process(self, formdata=None, obj=None, **kwargs): + super().process(formdata=formdata, obj=obj, **kwargs) + if obj is not None: + self.allergies.data_other = obj.allergies_other + self.dietary_restrictions.data_other = obj.dietary_restrictions_other + def validate_volunteer_email(form, field): if current_user.is_anonymous and User.does_user_exist(field.data): field.was_duplicate = True @@ -54,6 +159,10 @@ def update_volunteer_from_form(volunteer, form): volunteer.volunteer_email = form.volunteer_email.data volunteer.volunteer_phone = form.volunteer_phone.data volunteer.over_18 = form.over_18.data + volunteer.allergies = form.allergies.data + volunteer.allergies_other = form.allergies.data_other + volunteer.dietary_restrictions = form.dietary_restrictions.data + volunteer.dietary_restrictions_other = form.dietary_restrictions.data_other return volunteer diff --git a/config/development-example.cfg b/config/development-example.cfg index d1152efb4..bd2f5d6c2 100644 --- a/config/development-example.cfg +++ b/config/development-example.cfg @@ -111,3 +111,6 @@ ETHNICITY_MATCHERS = { ), "other": r"^other$", } + +BUILDUP_SECRET = "buildup" +BUILDUP_SIGNAL_GROUP = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" \ No newline at end of file diff --git a/config/kelvin.cfg b/config/kelvin.cfg index ec3af33cf..8d9143981 100644 --- a/config/kelvin.cfg +++ b/config/kelvin.cfg @@ -97,3 +97,5 @@ DEFAULT_FLOW = 'main' ARRIVAL_DAYS = 2 DEPARTURE_DAYS = 2 +BUILDUP_SECRET = "buildup" +BUILDUP_SIGNAL_GROUP = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" \ No newline at end of file diff --git a/config/test.cfg b/config/test.cfg index fa03917b2..6302c6b97 100644 --- a/config/test.cfg +++ b/config/test.cfg @@ -58,3 +58,6 @@ EVENT_END = "2019-09-02 19:00:00" # Presented to volunteers when signing up ARRIVAL_DAYS = 2 DEPARTURE_DAYS = 2 + +BUILDUP_SECRET = "buildup" +BUILDUP_SIGNAL_GROUP = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" \ No newline at end of file diff --git a/main.py b/main.py index f8924ad1e..939be3cbe 100644 --- a/main.py +++ b/main.py @@ -149,7 +149,29 @@ def derive_secret_key(app: Flask): app.config["SECRET_KEY"] = kdf.derive(bytes(app.config["SECRET_KEY"], "utf-8")) +def _flask_admin_monkey(): + import flask_admin + + assert flask_admin.__version__ == "1.6.1", ( + "Please remove the monkey patches from main.py; they were necessary for WTForms 3.x support" + ) + from flask_admin.contrib.sqla.validators import Unique + + Unique.field_flags = {"unique": True} + from flask_admin.contrib.sqla.fields import QuerySelectField + + old_iter_choices = QuerySelectField.iter_choices + + def _new_iter_choices(self): + for val, label, selected in old_iter_choices(self): + yield val, label, selected, {} + + QuerySelectField.iter_choices = _new_iter_choices + + def create_app(dev_server=False, config_override=None): + _flask_admin_monkey() + app = Flask(__name__, static_folder="dist/static") app.config.from_envvar("SETTINGS_FILE") if config_override: diff --git a/migrations/versions/1ac6eca4a1d7_add_buildup_volunteer.py b/migrations/versions/1ac6eca4a1d7_add_buildup_volunteer.py new file mode 100644 index 000000000..2ac7df30c --- /dev/null +++ b/migrations/versions/1ac6eca4a1d7_add_buildup_volunteer.py @@ -0,0 +1,81 @@ +"""Add buildup volunteer + +Revision ID: 1ac6eca4a1d7 +Revises: b50eec447f11 +Create Date: 2025-09-08 22:25:32.661671 + +""" + +# revision identifiers, used by Alembic. +revision = '1ac6eca4a1d7' +down_revision = 'b50eec447f11' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('buildup_signup_key', + sa.Column('token', sa.String(), nullable=False), + sa.Column('team_name', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('token', name=op.f('pk_buildup_signup_key')) + ) + op.create_table('buildup_signup_key_version', + sa.Column('token', sa.String(), autoincrement=False, nullable=False), + sa.Column('team_name', sa.String(), autoincrement=False, nullable=True), + sa.Column('transaction_id', sa.BigInteger(), autoincrement=False, nullable=False), + sa.Column('operation_type', sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint('token', 'transaction_id', name=op.f('pk_buildup_signup_key_version')) + ) + with op.batch_alter_table('buildup_signup_key_version', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_buildup_signup_key_version_operation_type'), ['operation_type'], unique=False) + batch_op.create_index(batch_op.f('ix_buildup_signup_key_version_transaction_id'), ['transaction_id'], unique=False) + + op.create_table('buildup_volunteer_version', + sa.Column('id', sa.Integer(), autoincrement=False, nullable=False), + sa.Column('user_id', sa.Integer(), autoincrement=False, nullable=True), + sa.Column('team_name', sa.String(), autoincrement=False, nullable=True), + sa.Column('arrival_date', sa.DateTime(), autoincrement=False, nullable=True), + sa.Column('departure_date', sa.DateTime(), autoincrement=False, nullable=True), + sa.Column('emergency_contact', sa.String(), autoincrement=False, nullable=True), + sa.Column('acked_health_and_safety_briefing_at', sa.DateTime(), autoincrement=False, nullable=True), + sa.Column('recorded_on_site', sa.DateTime(), autoincrement=False, nullable=True), + sa.Column('transaction_id', sa.BigInteger(), autoincrement=False, nullable=False), + sa.Column('operation_type', sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint('id', 'transaction_id', name=op.f('pk_buildup_volunteer_version')) + ) + with op.batch_alter_table('buildup_volunteer_version', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_buildup_volunteer_version_operation_type'), ['operation_type'], unique=False) + batch_op.create_index(batch_op.f('ix_buildup_volunteer_version_transaction_id'), ['transaction_id'], unique=False) + + op.create_table('buildup_volunteer', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('team_name', sa.String(), nullable=False), + sa.Column('arrival_date', sa.DateTime(), nullable=False), + sa.Column('departure_date', sa.DateTime(), nullable=False), + sa.Column('emergency_contact', sa.String(), nullable=False), + sa.Column('acked_health_and_safety_briefing_at', sa.DateTime(), nullable=True), + sa.Column('recorded_on_site', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_buildup_volunteer_user_id_user')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_buildup_volunteer')) + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('buildup_volunteer') + with op.batch_alter_table('buildup_volunteer_version', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_buildup_volunteer_version_transaction_id')) + batch_op.drop_index(batch_op.f('ix_buildup_volunteer_version_operation_type')) + + op.drop_table('buildup_volunteer_version') + with op.batch_alter_table('buildup_signup_key_version', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_buildup_signup_key_version_transaction_id')) + batch_op.drop_index(batch_op.f('ix_buildup_signup_key_version_operation_type')) + + op.drop_table('buildup_signup_key_version') + op.drop_table('buildup_signup_key') + # ### end Alembic commands ### diff --git a/migrations/versions/b50eec447f11_add_allergies_dietary_requirements_to_.py b/migrations/versions/b50eec447f11_add_allergies_dietary_requirements_to_.py new file mode 100644 index 000000000..22566dc47 --- /dev/null +++ b/migrations/versions/b50eec447f11_add_allergies_dietary_requirements_to_.py @@ -0,0 +1,48 @@ +"""Add allergies/dietary requirements to volunteer + +Revision ID: b50eec447f11 +Revises: 5062a9a72efc +Create Date: 2025-09-08 21:28:33.693865 + +""" + +# revision identifiers, used by Alembic. +revision = 'b50eec447f11' +down_revision = '5062a9a72efc' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('volunteer', schema=None) as batch_op: + batch_op.add_column(sa.Column('allergies', sa.ARRAY(sa.String()), nullable=False)) + batch_op.add_column(sa.Column('allergies_other', sa.String(), nullable=False)) + batch_op.add_column(sa.Column('dietary_restrictions', sa.ARRAY(sa.String()), nullable=False)) + batch_op.add_column(sa.Column('dietary_restrictions_other', sa.String(), nullable=False)) + + with op.batch_alter_table('volunteer_version', schema=None) as batch_op: + batch_op.add_column(sa.Column('allergies', sa.ARRAY(sa.String()), autoincrement=False, nullable=True)) + batch_op.add_column(sa.Column('allergies_other', sa.String(), autoincrement=False, nullable=True)) + batch_op.add_column(sa.Column('dietary_restrictions', sa.ARRAY(sa.String()), autoincrement=False, nullable=True)) + batch_op.add_column(sa.Column('dietary_restrictions_other', sa.String(), autoincrement=False, nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('volunteer_version', schema=None) as batch_op: + batch_op.drop_column('dietary_restrictions_other') + batch_op.drop_column('dietary_restrictions') + batch_op.drop_column('allergies_other') + batch_op.drop_column('allergies') + + with op.batch_alter_table('volunteer', schema=None) as batch_op: + batch_op.drop_column('dietary_restrictions_other') + batch_op.drop_column('dietary_restrictions') + batch_op.drop_column('allergies_other') + batch_op.drop_column('allergies') + + # ### end Alembic commands ### diff --git a/models/user.py b/models/user.py index 84fe157fe..f99bb111b 100644 --- a/models/user.py +++ b/models/user.py @@ -34,7 +34,7 @@ from .payment import Payment from .purchase import AdmissionTicket, Purchase, PurchaseTransfer, Ticket from .village import VillageMember - from .volunteer import RoleAdmin, Volunteer + from .volunteer import BuildupVolunteer, RoleAdmin, Volunteer __all__ = [ "AnonymousUser", @@ -292,6 +292,7 @@ class User(BaseModel, UserMixin): admin_messages: Mapped[list[AdminMessage]] = relationship("AdminMessage", back_populates="creator") + buildup_volunteer: Mapped[BuildupVolunteer | None] = relationship(back_populates="user") volunteer: Mapped[Volunteer | None] = relationship(back_populates="user") volunteer_admin_roles: Mapped[list[RoleAdmin]] = relationship(back_populates="user") shift_entries: Mapped[list[ShiftEntry]] = relationship(back_populates="user") diff --git a/models/volunteer/__init__.py b/models/volunteer/__init__.py index 3a566c6d9..67189865c 100644 --- a/models/volunteer/__init__.py +++ b/models/volunteer/__init__.py @@ -1,3 +1,4 @@ +from .buildup import * # noqa: F403 from .role import * # noqa: F403 from .shift import * # noqa: F403 from .venue import * # noqa: F403 diff --git a/models/volunteer/buildup.py b/models/volunteer/buildup.py new file mode 100644 index 000000000..a881f0057 --- /dev/null +++ b/models/volunteer/buildup.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from datetime import date, datetime, time, timedelta +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, select +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from main import db + +from .. import BaseModel, event_end, event_start + +if TYPE_CHECKING: + from .. import User + +__all__ = [ + "BuildupSignupKey", + "BuildupVolunteer", + "buildup_end", + "buildup_start", + "teardown_end", + "teardown_start", +] + + +class BuildupSignupKey(BaseModel): + __table_name__ = "buildup_signup_key" + __versioned__: dict = {} + + token: Mapped[str] = mapped_column(primary_key=True) + team_name: Mapped[str] + + +class BuildupVolunteer(BaseModel): + __table_name__ = "buildup_volunteer" + __versioned__: dict = {} + + id: Mapped[int] = mapped_column(primary_key=True) + + user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + user: Mapped[User] = relationship(back_populates="buildup_volunteer", foreign_keys=[user_id]) + + team_name: Mapped[str] = mapped_column(default="") + + arrival_date: Mapped[datetime] + departure_date: Mapped[datetime] + + emergency_contact: Mapped[str] = mapped_column(default="") + + acked_health_and_safety_briefing_at: Mapped[datetime | None] + recorded_on_site: Mapped[datetime | None] + + @classmethod + def get_for_user(cls, user: User) -> BuildupVolunteer | None: + return db.session.scalars(select(cls).where(BuildupVolunteer.user_id == user.id)).first() + + +def buildup_start() -> datetime: + # Beginning of day -7 + return datetime.combine(event_start().date() - timedelta(days=8), time(hour=0)) + + +def buildup_end() -> date: + # End of day 0 + return datetime.combine(event_start().date() - timedelta(days=1), time(hour=22)) + + +def teardown_start() -> date: + # We start considering teardown from "midday" on day 5 + return datetime.combine(event_end().date() + timedelta(days=1), time(hour=12)) + + +def teardown_end() -> date: + # After PM on day 8 + return datetime.combine(event_end().date() + timedelta(days=4), time(hour=22)) diff --git a/models/volunteer/volunteer.py b/models/volunteer/volunteer.py index 2b7114c05..014babe25 100644 --- a/models/volunteer/volunteer.py +++ b/models/volunteer/volunteer.py @@ -1,8 +1,11 @@ +from __future__ import annotations + from collections import defaultdict -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, TypeVar from flask_login import UserMixin -from sqlalchemy import Column, ForeignKey, Integer, Table +from sqlalchemy import ARRAY, Column, ForeignKey, Integer, String, Table +from sqlalchemy.ext.mutable import Mutable, MutableSet from sqlalchemy.orm import Mapped, mapped_column, relationship from .. import BaseModel @@ -18,6 +21,8 @@ "VolunteerRoleTraining", ] +_T = TypeVar("_T") + # This effectively records the roles that a volunteer is interested in VolunteerRoleInterest = Table( "volunteer_role_interest", @@ -36,6 +41,16 @@ ) +class MutableSetAsList(MutableSet[_T]): + @classmethod + def coerce(cls, index: str, value: Any) -> MutableSetAsList[_T] | None: + if not isinstance(value, cls): + if isinstance(value, set | list): + return cls(value) + return Mutable.coerce(index, value) + return value + + class Volunteer(BaseModel, UserMixin): __tablename__ = "volunteer" __versioned__: dict = {} @@ -46,18 +61,22 @@ class Volunteer(BaseModel, UserMixin): volunteer_phone: Mapped[str | None] volunteer_email: Mapped[str | None] over_18: Mapped[bool] = mapped_column(default=False) + allergies: Mapped[set[str]] = mapped_column(MutableSetAsList.as_mutable(ARRAY(String))) + allergies_other: Mapped[str] = mapped_column(default="") + dietary_restrictions: Mapped[set[str]] = mapped_column(MutableSetAsList.as_mutable(ARRAY(String))) + dietary_restrictions_other: Mapped[str] = mapped_column(default="") allow_comms_during_event: Mapped[bool] = mapped_column(default=False) user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) - user: Mapped["User"] = relationship(back_populates="volunteer") + user: Mapped[User] = relationship(back_populates="volunteer") - interested_roles: Mapped[list["Role"]] = relationship( + interested_roles: Mapped[list[Role]] = relationship( back_populates="interested_volunteers", secondary=VolunteerRoleInterest, lazy="dynamic", ) - trained_roles: Mapped[list["Role"]] = relationship( + trained_roles: Mapped[list[Role]] = relationship( back_populates="trained_volunteers", secondary=VolunteerRoleTraining, lazy="dynamic", diff --git a/templates/_formhelpers.html b/templates/_formhelpers.html index db7bddce0..32dcc3768 100644 --- a/templates/_formhelpers.html +++ b/templates/_formhelpers.html @@ -166,3 +166,37 @@ {% endmacro %} + +{% macro render_checkbox(field, horizontal=False) %} +{% if caller %} + {% set help_text = caller() %} +{% endif %} + +{% if horizontal == True %} + {% set horizontal = 10 %} +{% endif %} + +{% if horizontal != False %} + {% set labelw = 12 - horizontal %} + {% set fieldw = horizontal %} +{% endif %} + +{% if help_text or field.errors %} + {# If we have help text, add an aria-describedby attribute to the field. #} + {% do kwargs.update({'aria-describedby': "help-block-" + field.name}) %} +{% endif %} + +
+ + {% if field.errors %} + {% for error in field.errors %} +

{{ error }}

+ {% endfor %} + {% elif help_text %} +

{{ help_text }}

+ {% endif -%}
+{% endmacro %} + diff --git a/templates/volunteer/_buildup-safety.html b/templates/volunteer/_buildup-safety.html new file mode 100644 index 000000000..c8ba84458 --- /dev/null +++ b/templates/volunteer/_buildup-safety.html @@ -0,0 +1,55 @@ +

Safety on Site

+

+ During buildup and teardown, EMF is a construction site. There are some + simple rules that everyone must follow to make sure everyone is safe. We + have to take safety seriously, so if you refuse to follow these rules, you + will be asked to leave the site. +

+

+ If you don't know how to do a task safety, or you're worried that something + is being done in an unsafe way, please ask. If someone + raises a point to you about safety, don't take it personally. +

+

+ If you still have any questions or concerns about safety, talk to Will or + Russ, who are responsible for safety on site. +

+ +

High-Vis

+

+ After arriving on site for build up or teardown, please visit the HQ and let + us know who you are. We'll issue you with a high-vis vest, which should be + worn at all times while you're walking around the site. +

+

+ Please return all high-vis to the HQ at the end of buildup on Wednesday. + High-vis must not be worn or carried visibly during the event unless you've + been asked to wear it for a volunteer role. +

+ +

Vehicle Safety

+ + +

Footwear

+

+ A surprisingly large number of injuries during buildup and teardown are caused + by inappropriate footwear. If you're wearing grossly inappropriate footwear we + will not allow you to work. +

+

+ If you're not doing manual handling or other heavy work, a good pair of walking + boots will work fine. Otherwise, waterproof steel-toed boots are strongly + recommended. +

\ No newline at end of file diff --git a/templates/volunteer/_details_form.html b/templates/volunteer/_details_form.html new file mode 100644 index 000000000..b56e70293 --- /dev/null +++ b/templates/volunteer/_details_form.html @@ -0,0 +1,13 @@ +{% from "_formhelpers.html" import render_field, render_checkbox %} + +{% macro volunteer_details_form(form, show_save_button) %} + {{ render_field(form.nickname, horizontal=9) }} + {{ render_field(form.volunteer_email, horizontal=9) }} + {{ render_checkbox(form.over_18, horizontal=9) }} + {{ render_field(form.volunteer_phone, horizontal=9) }} + {% call render_field(form.allergies, horizontal=9) %}{% endcall %} + {% call render_field(form.dietary_restrictions, horizontal=9) %}{% endcall %} + {% if show_save_button %} + {{ form.save(class_="btn btn-primary debounce pull-right") }} + {% endif %} +{% endmacro %} diff --git a/templates/volunteer/_nav.html b/templates/volunteer/_nav.html index 4c3ca288b..598949a3f 100644 --- a/templates/volunteer/_nav.html +++ b/templates/volunteer/_nav.html @@ -17,6 +17,9 @@ {% endif %} {{ menuitem('Bar Training', '.bar_training') }} {{ menuitem('Handbook', '.info') }} + {% if current_user.has_permission('volunteer:buildup') %} + {{ menuitem('Buildup/Teardown Registration', '.buildup_amend')}} + {% endif %} {% if current_user.has_permission('volunteer:admin') or current_user.has_permission('volunteer:manager') or current_user.volunteer_admin_roles %} {{ menuitem('Role Admin', '.role_admin_index')}} {% endif %} diff --git a/templates/volunteer/account.html b/templates/volunteer/account.html index 45141203c..51387e5f1 100644 --- a/templates/volunteer/account.html +++ b/templates/volunteer/account.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% set main_class = 'volunteer-account' %} -{% from "_formhelpers.html" import render_field %} +{% from "volunteer/_details_form.html" import volunteer_details_form %} {% block title %} EMF Volunteer Account @@ -27,18 +27,7 @@

Update your volunteer details

{# FIXME #}
- {{ render_field(form.nickname, horizontal=10) }} - {{ render_field(form.volunteer_email, horizontal=10) }} -
-
- {{ form.over_18(class_='big-checkbox') }} - -
-
- {{ render_field(form.volunteer_phone, horizontal=10) }} - {{ form.save(class_="btn btn-primary debounce pull-right") }} + {{ volunteer_details_form(form, True) }}

{# FIXME #} diff --git a/templates/volunteer/admin/_nav.html b/templates/volunteer/admin/_nav.html index c23c3f048..66e4958b7 100644 --- a/templates/volunteer/admin/_nav.html +++ b/templates/volunteer/admin/_nav.html @@ -17,14 +17,56 @@ {% if current_user.has_permission('volunteer:admin') %} {%- for item in admin_view.admin.menu() %} {%- if item.is_accessible() and item.is_visible() -%} - {% set class_name = item.get_class_name() %} - {%- if item.is_active(admin_view) %} -
  • + {%- if item.is_category() -%} + {% set children = item.get_children() %} + {%- if children %} + {% set class_name = item.get_class_name() or '' %} + {%- if item.is_active(admin_view) %} +
  • + {% endif %} {%- else %} - - {%- endif %} - {{ menu_icon(item) }}{{ item.name }} - + {% set class_name = item.get_class_name() %} + {%- if item.is_active(admin_view) %} +
  • + {%- else %} + + {%- endif %} + {{ menu_icon(item) }}{{ item.name }} +
  • + {% endif -%} {% endif -%} {% endfor %} {{ menuitem("Notifications", "volunteer_admin_notify.main") }} diff --git a/templates/volunteer/admin/buildup_breakdown.html b/templates/volunteer/admin/buildup_breakdown.html new file mode 100644 index 000000000..fc833f228 --- /dev/null +++ b/templates/volunteer/admin/buildup_breakdown.html @@ -0,0 +1,69 @@ +{% extends "volunteer/admin/base.html" %} +{% block title %}Buildup Breakdown{% endblock %} +{% block body %} +

    Buildup Breakdown

    + + + + + + + + + + + + + {% set ns = namespace(prev=0) %} + {% for data in days_data %} + {% if data.is_just_after_event %} + + + + {% endif %} + + {% if ns.prev != data.date_str %} + + {% endif %} + + + + + + + {% set ns.prev = data.date_str %} + {% endfor %} + +
    DatePredictedActual
    THE EVENT
    {{ data.date_str }}{{ data.am_or_pm }} + {{ data.predicted_onsite }} + +
    +
    +
    +
    {{ data.arrived_onsite }} +
    +
    +
    +
    +{% endblock %} diff --git a/templates/volunteer/buildup-arrived.html b/templates/volunteer/buildup-arrived.html new file mode 100644 index 000000000..0b607b8e8 --- /dev/null +++ b/templates/volunteer/buildup-arrived.html @@ -0,0 +1,27 @@ +{% extends 'base.html' %} + +{% block title %} + Welcome to EMF Build Up +{% endblock %} +{% block body %} +{% include "volunteer/_nav.html" %} + +

    EMF Build Up

    +
    +
    +

    Thank you for helping with Electromagnetic Field {{ event_year }} build up!

    +
    +
    +

    + You're now recorded as being on site for build up. Welcome! +

    + {% if is_buildup %} +

    Communication

    +

    + You should join the EMF {{ event_year }} Build Up Signal group chat. +

    + {% endif %} + {% include "volunteer/_buildup-safety.html" %} +
    +
    +{% endblock %} diff --git a/templates/volunteer/buildup-sign-up.html b/templates/volunteer/buildup-sign-up.html new file mode 100644 index 000000000..5f2c5ba0d --- /dev/null +++ b/templates/volunteer/buildup-sign-up.html @@ -0,0 +1,65 @@ +{% extends 'base.html' %} +{% from "_formhelpers.html" import render_field, render_checkbox %} +{% from "volunteer/_details_form.html" import volunteer_details_form %} + +{% block title %} + EMF Build Up Registration +{% endblock %} +{% block body %} +{% include "volunteer/_nav.html" %} + +

    Buildup/Teardown Registration

    +
    +
    +

    Thank you for helping with Electromagnetic Field {{ event_year }} buildup/teardown!

    + {% if volunteer_form and not volunteer %} +

    + {% if current_user.is_authenticated %} + We've pre-filled some of your data from your existing profile. + {% else %} + We need a little information about you first. + {% endif %} + This information will be made available to volunteer managers and, should you + volunteer during the event, other volunteers on adjacent shifts. +

    +
    + {% if current_user.is_authenticated %} + If you would like to be contacted via a different email, or use a + different name, feel free to update it here. + {% else %} + If you already have an account, please log in first. {% if SITE_STATE == 'event' %}If you're arriving with someone else, you might want to ask them to transfer your ticket to you.{% endif %} + {% endif %} +
    + {% endif %} + {% include "volunteer/_buildup-safety.html" %} +
    + +
    +
    +
    + {% if volunteer_form.volunteer_email.was_duplicate %} +
    + This email address already exists in our system. + Please click here to log in.
    + {% endif %} + +
    + {{ volunteer_form.hidden_tag() }} + {{ volunteer_details_form(volunteer_form, False) }} +
    + + {{ buildup_form.hidden_tag() }} + {% call render_field(buildup_form.arrival_date, horizontal=9, min=buildup_start.isoformat(), max=teardown_end.isoformat()) %} + A rough time estimate so we know which meals you'll be around for is all we need here. + {% endcall %} + {{ render_field(buildup_form.departure_date, horizontal=9, min=buildup_start.isoformat(), max=teardown_end.isoformat()) }} + {{ render_field(buildup_form.emergency_contact, horizontal=9) }} + {{ render_checkbox(buildup_form.health_and_safety_briefing, horizontal=9) }} + + {{ buildup_form.save(class_="btn btn-primary debounce pull-right") }} +
    +
    +
    +
    +
    +{% endblock %} diff --git a/templates/volunteer/sign-up.html b/templates/volunteer/sign-up.html index fdcc1e46c..97f5cda1c 100644 --- a/templates/volunteer/sign-up.html +++ b/templates/volunteer/sign-up.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% set main_class = 'volunteer-signup' %} -{% from "_formhelpers.html" import render_field %} +{% from "volunteer/_details_form.html" import volunteer_details_form %} {% block title %} EMF Volunteer Sign-Up @@ -43,18 +43,7 @@

    Thank you for helping make Electromagnetic Field {{ event_year }} run smooth
    {{ form.hidden_tag() }} - {{ render_field(form.nickname, horizontal=10) }} - {{ render_field(form.volunteer_email, horizontal=10) }} -
    -
    - {{ form.over_18(class_='big-checkbox') }} - -
    -
    - {{ render_field(form.volunteer_phone, horizontal=10) }} - {{ form.sign_up(class_="btn btn-primary debounce pull-right") }} + {{ volunteer_details_form(form, True) }}

    {# FIXME #}