diff --git a/.vscode/settings.json b/.vscode/settings.json index 380c07a..eef58b5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,7 +3,6 @@ ".venv/**": true, "**/__pycache__/**": true }, - "python.formatting.provider": "none", "[python]": { "editor.formatOnSave": true, "editor.defaultFormatter": "ms-python.black-formatter" diff --git a/migrations/versions/0f07a3edd643_role_system.py b/migrations/versions/0f07a3edd643_role_system.py new file mode 100644 index 0000000..7586a54 --- /dev/null +++ b/migrations/versions/0f07a3edd643_role_system.py @@ -0,0 +1,112 @@ +"""Role System + +Revision ID: 0f07a3edd643 +Revises: 9b0e597da71c +Create Date: 2023-09-16 12:46:50.000739 + +""" +import sqlalchemy as sa +import sqlalchemy.orm as orm +from alembic import op +from sqlalchemy.future import select + +# revision identifiers, used by Alembic. +revision = "0f07a3edd643" +down_revision = "9b0e597da71c" +branch_labels = None +depends_on = None + +users_table = sa.table( + "users", sa.column("name", sa.String), sa.column("role_id", sa.Integer) +) +roles_table = sa.table( + "roles", sa.column("name", sa.String), sa.column("id", sa.Integer) +) +account_types_table = sa.table( + "account_types", sa.column("name", sa.String), sa.column("id", sa.Integer) +) +user_role_association_table = sa.table( + "user_role_association", + sa.column("user_id", sa.ForeignKey("users.id")), + sa.column("role_id", sa.ForeignKey("roles.id")), +) + + +def role_named(name): + return ( + roles_table.select() + .where(roles_table.c.name == op.inline_literal(name)) + .scalar_subquery() + ) + + +def account_type_named(name): + return ( + account_types_table.select() + .where(account_types_table.c.name == op.inline_literal(name)) + .scalar_subquery() + ) + + +def upgrade_users(session, oldrole, *newroles): + for r in newroles: + stmt = user_role_association_table.insert().values(role_id=role_named(r)) + print(stmt) + print("---") + # session.execute(stmt) + + +def downgrade_users(session: orm.Session, newrole: str, oldrole: str): + stmt = ( + users_table.update() + .where(user_role_association_table.c.role_id == role_named(newrole)) + .values(role_id=account_type_named(oldrole).c.id) + ) + print(stmt) + # session.execute(stmt) + + +def upgrade(): + bind = op.get_bind() + session = orm.Session(bind=bind) + + upgrade_users(session, "admin", "admin", "mentor", "display", "visible") + upgrade_users(session, "mentor", "mentor", "display", "visible") + upgrade_users(session, "display", "display", "autoload") + upgrade_users(session, "lead", "lead", "student", "funds", "visible") + upgrade_users(session, "student", "student", "funds", "visible") + upgrade_users(session, "guardian_limited", "guardian") + upgrade_users(session, "guardian", "guardian", "visible") + + session.commit() + return + + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.drop_constraint(None, type_="foreignkey") + batch_op.drop_column("role_id") + + # ### end Alembic commands ### + + +def downgrade(): + bind = op.get_bind() + session = orm.Session(bind=bind) + + downgrade_users(session, "admin", "admin") + downgrade_users(session, "guardian", "guardian") + downgrade_users(session, "student", "student") + downgrade_users(session, "lead", "lead") + downgrade_users(session, "autoload", "display") + downgrade_users(session, "mentor", "mentor") + downgrade_users(session, "admin", "admin") + return + + session.flush() + + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.add_column(sa.Column("role_id", sa.INTEGER(), nullable=False)) + batch_op.create_foreign_key(None, "account_types", ["role_id"], ["id"]) + + # ### end Alembic commands ### diff --git a/signinapp/__init__.py b/signinapp/__init__.py index 71fda82..8c93271 100644 --- a/signinapp/__init__.py +++ b/signinapp/__init__.py @@ -188,13 +188,15 @@ def create_if_not_exists(cls, name, **kwargs): def init_default_db(): - create_if_not_exists(Role, name="admin", mentor=True, can_display=True, admin=True) - create_if_not_exists(Role, name="mentor", mentor=True, can_display=True) - create_if_not_exists(Role, name="display", can_display=True, autoload=True) - create_if_not_exists(Role, name="lead", can_see_subteam=True, receives_funds=True) - create_if_not_exists(Role, name="student", default_role=True, receives_funds=True) - create_if_not_exists(Role, name="guardian_limited", guardian=True, visible=False) - create_if_not_exists(Role, name="guardian", guardian=True) + create_if_not_exists(Role, name="admin") + create_if_not_exists(Role, name="mentor") + create_if_not_exists(Role, name="display") + create_if_not_exists(Role, name="lead") + create_if_not_exists(Role, name="student") + create_if_not_exists(Role, name="guardian") + create_if_not_exists(Role, name="funds") + create_if_not_exists(Role, name="autoload") + create_if_not_exists(Role, name="visible") create_if_not_exists( EventType, name="Training", description="Training Session", autoload=True @@ -214,10 +216,20 @@ def init_default_db(): db.session.commit() if not User.from_email("admin@signin"): - User.make("admin@signin", "admin", password="1234", role="admin", approved=True) + User.make( + "admin@signin", + "admin", + password="1234", + roles=["admin", "mentor", "visible", "display"], + approved=True, + ) if not User.from_email("display@signin"): User.make( - "display@signin", "display", password="1234", role="display", approved=True + "display@signin", + "display", + password="1234", + roles=["display", "autoload"], + approved=True, ) db.session.commit() @@ -268,7 +280,7 @@ def init_default_db(): address="123 First Street", tshirt_size="Large", password="1234", - role="mentor", + roles=["mentor", "display"], approved=True, ) student_user = Student.make( diff --git a/signinapp/admin/__init__.py b/signinapp/admin/__init__.py index 15e4c07..d6b1d05 100644 --- a/signinapp/admin/__init__.py +++ b/signinapp/admin/__init__.py @@ -3,7 +3,7 @@ from sqlalchemy import delete from ..util import admin_required -from . import role, subteam, users # noqa +from . import subteam, users # noqa from .util import admin diff --git a/signinapp/admin/role.py b/signinapp/admin/role.py deleted file mode 100644 index 68d86fd..0000000 --- a/signinapp/admin/role.py +++ /dev/null @@ -1,64 +0,0 @@ -from flask import flash, redirect, request, url_for -from flask.templating import render_template -from flask_wtf import FlaskForm -from sqlalchemy.future import select -from wtforms import BooleanField, StringField, SubmitField -from wtforms.validators import DataRequired - -from ..model import Role, db -from ..util import admin_required -from .util import admin - - -class RoleForm(FlaskForm): - name = StringField(validators=[DataRequired()]) - admin = BooleanField() - mentor = BooleanField() - can_display = BooleanField("Can display event pages") - autoload = BooleanField("Automatically load event pages") - can_see_subteam = BooleanField("Can view subteam stamps") - default_role = BooleanField("Is the default role for new accounts") - receives_funds = BooleanField("Receives Funds") - submit = SubmitField() - - -@admin.route("/admin/roles", methods=["GET", "POST"]) -@admin_required -def roles(): - roles = db.session.scalars(select(Role)) - return render_template("admin/roles.html.jinja2", roles=roles) - - -@admin.route("/admin/roles/new", methods=["GET", "POST"]) -@admin_required -def new_role(): - form = RoleForm() - if form.validate_on_submit(): - r = Role() - form.populate_obj(r) - if form.default_role.data: - Role.set_default(r) - db.session.add(r) - db.session.commit() - return redirect(url_for("admin.subteams")) - - return render_template("form.html.jinja2", form=form, title="New Role") - - -@admin.route("/admin/roles/edit", methods=["GET", "POST"]) -@admin_required -def edit_role(): - r = db.session.get(Role, request.args["role_id"]) - if not r: - flash("Invalid role ID") - return redirect(url_for("admin.roles")) - - form = RoleForm(obj=r) - if form.validate_on_submit(): - form.populate_obj(r) - if form.default_role.data: - Role.set_default(r) - db.session.commit() - return redirect(url_for("admin.subteams")) - - return render_template("form.html.jinja2", form=form, title=f"Edit Role {r.name}") diff --git a/signinapp/admin/users.py b/signinapp/admin/users.py index 90bfc1b..e0ac540 100644 --- a/signinapp/admin/users.py +++ b/signinapp/admin/users.py @@ -106,7 +106,10 @@ def edit_user(): user.name = form.name.data if form.password.data: user.password = generate_password_hash(form.password.data) - user.role_id = form.admin_data.role.data + + user.roles = [ + Role.from_name(r) for r in form.admin_data.data["roles"] if r and r != "0" + ] user.subteam_id = form.subteam.data or None user.approved = form.admin_data.approved.data user.preferred_name = form.preferred_name.data @@ -118,8 +121,7 @@ def edit_user(): return redirect(url_for("team.users")) form.phone_number.process_data(user.formatted_phone_number) - - form.admin_data.role.process_data(user.role_id) + form.admin_data.roles.process_data([role.id for role in user.roles]) form.admin_data.approved.process_data(user.approved) form.subteam.process_data(user.subteam_id) form.tshirt_size.process_data( @@ -167,7 +169,7 @@ def edit_student_data(): @admin_required def edit_guardian_data(): user: User = db.session.get(User, request.args["user_id"]) - if not user or not user.role.guardian: + if not user or not user.has_role("guardian"): flash("Invalid guardian user ID") return redirect(url_for("team.list_guardians")) diff --git a/signinapp/badge.py b/signinapp/badge.py index 145419d..a72a428 100644 --- a/signinapp/badge.py +++ b/signinapp/badge.py @@ -41,7 +41,7 @@ def view(): bid = int(bid) badge: Badge = db.session.get(Badge, bid) awards: list[BadgeAward] = sorted( - [a for a in badge.awards], key=lambda u: u.owner.name + [u for u in badge.awards], key=lambda u: u.owner.name ) return render_template("badge.html.jinja2", badge=badge, awards=awards) return redirect(url_for("mentor.all_badges", badge_id=badge.id)) diff --git a/signinapp/dbadmin.py b/signinapp/dbadmin.py index b6543ee..5fd171a 100644 --- a/signinapp/dbadmin.py +++ b/signinapp/dbadmin.py @@ -19,7 +19,7 @@ class AuthModelView(ModelView): def is_accessible(self): - return current_user.is_authenticated and current_user.role.admin + return current_user.is_authenticated and current_user.has_role("admin") def inaccessible_callback(self, name, **kwargs): # redirect to login page if user doesn't have access @@ -28,13 +28,13 @@ def inaccessible_callback(self, name, **kwargs): class AdminView(AdminIndexView): def is_accessible(self): - return current_user.is_authenticated and current_user.role.admin + return current_user.is_authenticated and current_user.has_role("admin") def inaccessible_callback(self, name, **kwargs): # redirect to login page if user doesn't have access if not current_user.is_authenticated: return redirect(url_for("auth.login", next=request.url)) - elif not current_user.role.admin: + elif not current_user.has_role("admin"): return abort(401) diff --git a/signinapp/event.py b/signinapp/event.py index 2fd7df6..eb5d000 100644 --- a/signinapp/event.py +++ b/signinapp/event.py @@ -26,7 +26,7 @@ @eventbp.route("/event") @login_required def event(): - if not current_user.role.can_display: + if not current_user.has_role("display"): flash("You don't have permissions to view the event scan page") return redirect(url_for("index")) event_code = request.values.get("event_code") @@ -84,7 +84,7 @@ def selfout(): def scan(): """This function returns a JSON object, not a web page.""" - if not current_user.role.can_display: + if not current_user.has_role("display"): return Response( "Error: User does not have permission to view active stamps", HTTPStatus.FORBIDDEN, @@ -123,7 +123,7 @@ def scan(): def autoevent(): """This function returns a JSON object, not a web page.""" - if not current_user.role.can_display: + if not current_user.has_role("display"): return Response( "Error: User does not have permission to view active stamps", HTTPStatus.FORBIDDEN, @@ -145,7 +145,7 @@ def active(): if not current_user.approved: return Response("Error: User is not approved", HTTPStatus.FORBIDDEN) - if not current_user.role.can_display: + if not current_user.has_role("display"): return Response( "Error: User does not have permission to view active stamps", HTTPStatus.FORBIDDEN, @@ -205,7 +205,7 @@ def export_stamps( @eventbp.route("/export") @login_required def export(): - if current_user.role.admin: + if current_user.has_role("admin"): name = request.values.get("name", None) else: name = current_user.name @@ -221,7 +221,10 @@ def export(): @eventbp.route("/export/subteam") @login_required def export_subteam(): - if not current_user.can_see_subteam or not current_user.subteam_id: + if ( + not (current_user.has_role("lead") or current_user.has_role("mentor")) + or not current_user.subteam_id + ): return current_app.login_manager.unauthorized() subteam = current_user.subteam diff --git a/signinapp/forms.py b/signinapp/forms.py index 9dd2b7a..9069fc3 100644 --- a/signinapp/forms.py +++ b/signinapp/forms.py @@ -26,6 +26,7 @@ generate_grade_choices, get_form_ids, ) +from .util import MultiCheckboxField NAME_RE = regex.compile(r"^(\p{L}+(['\-]\p{L}+)*)( \p{L}+(['\-]\p{L}+)*)*$") ADDRESS_RE = regex.compile(r"[A-Za-z0-9'\.\-\s\,]+") @@ -75,7 +76,7 @@ class StudentDataForm(Form): class AdminUserForm(Form): - role = SelectField(choices=lambda: get_form_ids(Role)) + roles = MultiCheckboxField(choices=lambda: get_form_ids(Role)) approved = BooleanField() diff --git a/signinapp/model.py b/signinapp/model.py index e1ca39c..da411fc 100644 --- a/signinapp/model.py +++ b/signinapp/model.py @@ -45,6 +45,22 @@ NonNullBool = Annotated[bool, mapped_column(default=False)] +parent_child_association_table = db.Table( + "parent_child_association", + db.metadata, + db.Column("guardians", db.ForeignKey("guardians.id"), primary_key=True), + db.Column("user_id", db.ForeignKey("students.id"), primary_key=True), +) + + +user_role_association_table = db.Table( + "user_role_association", + db.metadata, + db.Column("user_id", db.ForeignKey("users.id"), primary_key=True), + db.Column("role_id", db.ForeignKey("roles.id"), primary_key=True), +) + + def gen_code(): "Generate an event code" return secrets.token_urlsafe(16) @@ -99,7 +115,7 @@ class Badge(db.Model): icon: Mapped[str | None] color: Mapped[str] = mapped_column(default="black") - awards: Mapped[BadgeAward] = db.relationship(back_populates="badge") + awards: Mapped[list[BadgeAward]] = db.relationship(back_populates="badge") @staticmethod def from_name(name) -> Badge: @@ -122,14 +138,6 @@ def __init__(self, badge=None, owner=None): self.badge = badge -parent_child_association_table = db.Table( - "parent_child_association", - db.metadata, - db.Column("guardians", db.ForeignKey("guardians.id"), primary_key=True), - db.Column("user_id", db.ForeignKey("students.id"), primary_key=True), -) - - class User(UserMixin, db.Model): __tablename__ = "users" id: Mapped[intpk] @@ -152,7 +160,7 @@ class User(UserMixin, db.Model): back_populates="user", cascade="all, delete, delete-orphan", ) - role: Mapped[Role] = db.relationship(back_populates="users") + role: Mapped[AccountType] = db.relationship(back_populates="users") subteam: Mapped[Subteam] = db.relationship(back_populates="members") awards: Mapped[list[BadgeAward]] = db.relationship( @@ -172,6 +180,17 @@ class User(UserMixin, db.Model): cascade="all, delete, delete-orphan", ) + # Many to Many: roles + roles: Mapped[list[Role]] = db.relationship( + secondary=user_role_association_table, back_populates="users" + ) + # Wrapper to access roles via name + role_names: AssociationProxy[list[Role]] = association_proxy("roles", "name") + + def has_role(self, role_name) -> bool: + "Test for whether the user as the given role" + return role_name in self.role_names + @hybrid_property def is_active(self) -> bool: "Required by Flask-Login" @@ -213,8 +232,8 @@ def stamps_for_event(self, event: Event) -> list[Stamps]: def can_view(self, user: User): "Whether the user in question can view this user" return ( - self.role.mentor - or self.role.admin + self.has_role("mentor") + or self.has_role("admin") or (self == user) or ( self.guardian_user_data @@ -226,7 +245,7 @@ def can_view(self, user: User): @property def human_readable(self) -> str: "Human readable string for display on a web page" - return f"{'*' if self.role.mentor else ''}{self.display_name}" + return f"{'*' if self.has_role('mentor') else ''}{self.display_name}" @property def display_name(self) -> str: @@ -251,16 +270,16 @@ def total_funds(self) -> str: @staticmethod def get_visible_users() -> list[User]: - return db.session.scalars(select(User).where(User.role.has(visible=True))) + return db.session.scalars(select(User).where(User.role_names == "visible")) @staticmethod def make( email: str, name: str, password: str, - role: Role | str, approved=False, subteam: Subteam | str = None, + roles: list[Role | str] = [], **kwargs, ) -> User: "Make a user, with password and hash" @@ -269,9 +288,6 @@ def make( kwargs["phone_number"] ) - if isinstance(role, str): - role = Role.from_name(role) - if isinstance(subteam, str): subteam = Subteam.from_name(subteam) @@ -279,25 +295,31 @@ def make( email=email, name=name, password=generate_password_hash(password), - role_id=role.id, subteam_id=subteam.id if subteam else None, approved=approved, **kwargs, ) + db.session.add(user) db.session.flush() + + for role in roles: + if isinstance(role, str): + role = Role.from_name(role) + user.roles.append(role) + + db.session.flush() return user @staticmethod def make_guardian(name: str, phone_number: str, email: str): - role = Role.from_name("guardian_limited") pn = normalize_phone_number_for_storage(phone_number) guardian = User( name=name, email=email, - role_id=role.id, phone_number=pn, ) + guardian.roles.append(Role.from_name("guardian")) db.session.add(guardian) db.session.flush() return guardian @@ -377,14 +399,13 @@ def update_guardians(self, gs: FieldList): for guard in (guard.data for guard in gs): if guard["name"] and guard["phone_number"] and guard["email"]: i += 1 - self.add_guardian( - guardian=Guardian.get_from( - name=guard["name"], - phone_number=guard["phone_number"], - email=guard["email"], - contact_order=i, - ) + guardian = Guardian.get_from( + name=guard["name"], + phone_number=guard["phone_number"], + email=guard["email"], + contact_order=i, ) + self.add_guardian(guardian=guardian) @property def display_grade(self): @@ -398,9 +419,12 @@ def display_grade(self): def make( email: str, name: str, password: str, graduation_year: int, **kwargs ) -> User: - role = Role.from_name("student") student = User.make( - name=name, email=email, password=password, role=role, **kwargs + name=name, + email=email, + password=password, + roles=["student", "funds"] + kwargs.get("roles", []), + **kwargs, ) student_user_data = Student(user_id=student.id, graduation_year=graduation_year) @@ -541,12 +565,12 @@ def total_time(self) -> timedelta: def raw_funds_for(self, user: User) -> float: "Calculate funds from an event for the given user" - if not user.role.receives_funds: + if not user.has_role("funds"): return 0.0 user_stamps = user.stamps_for_event(self) user_hours = sum((stamp.elapsed for stamp in user_stamps), start=timedelta()) total_hours = sum( - (stamp.elapsed for stamp in self.stamps if stamp.user.role.receives_funds), + (stamp.elapsed for stamp in self.stamps if stamp.user.has_role("funds")), start=timedelta(), ) user_proportion = (user_hours / total_hours) if total_hours else 0.0 @@ -682,7 +706,7 @@ def elapsed(self) -> timedelta: return self.end - self.start -class Role(db.Model): +class AccountType(db.Model): __tablename__ = "account_types" id: Mapped[intpk] name: Mapped[str] @@ -699,22 +723,25 @@ class Role(db.Model): users: Mapped[list[User]] = db.relationship(back_populates="role") - @staticmethod - def from_name(name) -> Role: + @classmethod + def from_name(cls, name) -> Role: "Get a role by name" - return db.session.scalar(select(Role).filter_by(name=name)) + return db.session.scalar(select(cls).filter_by(name=name)) - @staticmethod - def get_default() -> Role: - "Get the default role" - return db.session.scalar(select(Role).filter_by(default_role=True)) + +class Role(db.Model): + __tablename__ = "roles" + id: Mapped[intpk] + name: Mapped[str] + + users: Mapped[list[User]] = db.relationship( + secondary=user_role_association_table, back_populates="roles" + ) @staticmethod - def set_default(def_role): - "Set the default role" - for role in db.session.scalar(select(Role)): - role.default_role = role == def_role - db.session.commit() + def from_name(name) -> Role: + "Get a role by name" + return db.session.scalar(select(Role).filter_by(name=name)) @staticmethod def get_visible() -> list[Role]: diff --git a/signinapp/team.py b/signinapp/team.py index 6d33fd8..ad9d091 100644 --- a/signinapp/team.py +++ b/signinapp/team.py @@ -43,25 +43,21 @@ def subteam(): @team.route("/users/students") @mentor_required def list_students(): - users = db.session.scalars( - select(User).where( - or_(User.role.has(name="student"), User.role.has(name="lead")) - ) - ) + users = db.session.scalars(select(User).where(User.role_names == "student")) return render_template("user_list.html.jinja2", role="Student", users=users) @team.route("/users/guardians") @mentor_required def list_guardians(): - users = db.session.scalars(select(User).where(User.role.has(guardian=True))) + users = db.session.scalars(select(User).where(User.role_names == "guardian")) return render_template("user_list.html.jinja2", role="Guardian", users=users) @team.route("/users/mentors") @mentor_required def list_mentors(): - users = db.session.scalars(select(User).where(User.role.has(mentor=True))) + users = db.session.scalars(select(User).where(User.role_names == "mentor")) return render_template("user_list.html.jinja2", role="Mentor", users=users) diff --git a/signinapp/templates/active.html.jinja2 b/signinapp/templates/active.html.jinja2 index 7226011..7abb988 100644 --- a/signinapp/templates/active.html.jinja2 +++ b/signinapp/templates/active.html.jinja2 @@ -10,7 +10,7 @@ Delete Expired Stamps - {%- if current_user.is_authenticated and current_user.role.admin -%} + {%- if current_user.is_authenticated and current_user.has_role("admin") -%} Delete All Stamps diff --git a/signinapp/templates/admin/roles.html.jinja2 b/signinapp/templates/admin/roles.html.jinja2 deleted file mode 100644 index ed2773b..0000000 --- a/signinapp/templates/admin/roles.html.jinja2 +++ /dev/null @@ -1,49 +0,0 @@ -{% extends "base.html.jinja2" %} -{% block title %} - Role Admin - Chop Shop Sign In -{% endblock title %} -{% block content %} -
| Role | -Is Admin | -Is Mentor | -Can Display | -Autoload | -Can See Subteam Info | -Receives Funds | -Edit | -
|---|---|---|---|---|---|---|---|
| {{ role.name }} | -{{ role.admin }} | -{{ role.mentor }} | -{{ role.can_display }} | -{{ role.autoload }} | -{{ role.can_see_subteam }} | -{{ role.receives_funds }} | -- Edit - | -