diff --git a/alembic/versions/c99026ab9918_add_additional_user_fields.py b/alembic/versions/c99026ab9918_add_additional_user_fields.py new file mode 100644 index 00000000..f0dc6540 --- /dev/null +++ b/alembic/versions/c99026ab9918_add_additional_user_fields.py @@ -0,0 +1,36 @@ +"""add additional user fields + +Revision ID: c99026ab9918 +Revises: 903d7c66ff1d +Create Date: 2018-10-15 11:10:46.073745 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c99026ab9918' +down_revision = '903d7c66ff1d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('citizenship', sa.String(), nullable=True)) + op.add_column('users', sa.Column('date_latest_training', sa.Date(), nullable=True)) + op.add_column('users', sa.Column('designation', sa.String(), nullable=True)) + op.add_column('users', sa.Column('phone_number', sa.String(), nullable=True)) + op.add_column('users', sa.Column('service_branch', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'service_branch') + op.drop_column('users', 'phone_number') + op.drop_column('users', 'designation') + op.drop_column('users', 'date_latest_training') + op.drop_column('users', 'citizenship') + # ### end Alembic commands ### diff --git a/atst/app.py b/atst/app.py index 884d2bee..0cc2ca13 100644 --- a/atst/app.py +++ b/atst/app.py @@ -15,6 +15,7 @@ from atst.routes import bp from atst.routes.workspaces import bp as workspace_routes from atst.routes.requests import requests_bp from atst.routes.dev import bp as dev_routes +from atst.routes.users import bp as user_routes from atst.routes.errors import make_error_pages from atst.domain.authnid.crl import CRLCache from atst.domain.auth import apply_authentication @@ -57,6 +58,7 @@ def make_app(config): app.register_blueprint(bp) app.register_blueprint(workspace_routes) app.register_blueprint(requests_bp) + app.register_blueprint(user_routes) if ENV != "prod": app.register_blueprint(dev_routes) diff --git a/atst/domain/users.py b/atst/domain/users.py index 3728c04f..7494f5f4 100644 --- a/atst/domain/users.py +++ b/atst/domain/users.py @@ -5,7 +5,7 @@ from atst.database import db from atst.models import User from .roles import Roles -from .exceptions import NotFoundError, AlreadyExistsError +from .exceptions import NotFoundError, AlreadyExistsError, UnauthorizedError class Users(object): @@ -53,7 +53,7 @@ class Users(object): return user @classmethod - def update(cls, user_id, atat_role_name): + def update_role(cls, user_id, atat_role_name): user = Users.get(user_id) atat_role = Roles.get(atat_role_name) @@ -63,3 +63,29 @@ class Users(object): db.session.commit() return user + + _UPDATEABLE_ATTRS = { + "first_name", + "last_name", + "email", + "phone_number", + "service_branch", + "citizenship", + "designation", + "date_latest_training", + } + + @classmethod + def update(cls, user, user_delta): + delta_set = set(user_delta.keys()) + if not set(delta_set).issubset(Users._UPDATEABLE_ATTRS): + unpermitted = delta_set - Users._UPDATEABLE_ATTRS + raise UnauthorizedError(user, "update {}".format(", ".join(unpermitted))) + + for key, value in user_delta.items(): + setattr(user, key, value) + + db.session.add(user) + db.session.commit() + + return user diff --git a/atst/forms/data.py b/atst/forms/data.py index f5757233..3c48f316 100644 --- a/atst/forms/data.py +++ b/atst/forms/data.py @@ -1,7 +1,7 @@ from atst.domain.roles import WORKSPACE_ROLES as WORKSPACE_ROLE_DEFINITIONS SERVICE_BRANCHES = [ - (None, "Select an option"), + ("", "Select an option"), ("Air Force, Department of the", "Air Force, Department of the"), ("Army and Air Force Exchange Service", "Army and Air Force Exchange Service"), ("Army, Department of the", "Army, Department of the"), diff --git a/atst/forms/edit_user.py b/atst/forms/edit_user.py index 0bf8f211..a1b3763c 100644 --- a/atst/forms/edit_user.py +++ b/atst/forms/edit_user.py @@ -1,7 +1,8 @@ import pendulum +from copy import deepcopy from wtforms.fields.html5 import DateField, EmailField, TelField from wtforms.fields import RadioField, StringField -from wtforms.validators import Email, Required +from wtforms.validators import Email, Required, Optional from .fields import SelectField from .forms import ValidatedForm @@ -9,42 +10,33 @@ from .data import SERVICE_BRANCHES from .validators import Alphabet, DateRange, PhoneNumber - -class EditUserForm(ValidatedForm): - - first_name = StringField("First Name", validators=[Required(), Alphabet()]) - - last_name = StringField("Last Name", validators=[Required(), Alphabet()]) - - email = EmailField( +USER_FIELDS = { + "first_name": StringField("First Name", validators=[Alphabet()]), + "last_name": StringField("Last Name", validators=[Alphabet()]), + "email": EmailField( "E-mail Address", description="Enter your preferred contact e-mail address", - validators=[Required(), Email()], - ) - - phone_number = TelField( + validators=[Email()], + ), + "phone_number": TelField( "Phone Number", description="Enter your 10-digit U.S. phone number", - validators=[Required(), PhoneNumber()], - ) - - service_branch = SelectField( + validators=[PhoneNumber()], + ), + "service_branch": SelectField( "Service Branch or Agency", description="Which service or organization do you belong to within the DoD?", choices=SERVICE_BRANCHES, - ) - - citizenship = RadioField( + ), + "citizenship": RadioField( description="What is your citizenship status?", choices=[ ("United States", "United States"), ("Foreign National", "Foreign National"), ("Other", "Other"), ], - validators=[Required()], - ) - - designation = RadioField( + ), + "designation": RadioField( "Designation of Person", description="What is your designation within the DoD?", choices=[ @@ -52,19 +44,43 @@ class EditUserForm(ValidatedForm): ("civilian", "Civilian"), ("contractor", "Contractor"), ], - validators=[Required()], - ) - - date_latest_training = DateField( + ), + "date_latest_training": DateField( "Latest Information Assurance (IA) Training Completion Date", description='To complete the training, you can find it in Information Assurance Cyber Awareness Challange website.', validators=[ - Required(), DateRange( lower_bound=pendulum.duration(years=1), upper_bound=pendulum.duration(days=0), message="Must be a date within the last year.", - ), + ) ], format="%m/%d/%Y", + ), +} + + +def inherit_field(unbound_field, required=True): + kwargs = deepcopy(unbound_field.kwargs) + if not "validators" in kwargs: + kwargs["validators"] = [] + + if required: + kwargs["validators"].append(Required()) + else: + kwargs["validators"].append(Optional()) + + return unbound_field.field_class(*unbound_field.args, **kwargs) + + +class EditUserForm(ValidatedForm): + first_name = inherit_field(USER_FIELDS["first_name"]) + last_name = inherit_field(USER_FIELDS["last_name"]) + email = inherit_field(USER_FIELDS["email"]) + phone_number = inherit_field(USER_FIELDS["phone_number"], required=False) + service_branch = inherit_field(USER_FIELDS["service_branch"], required=False) + citizenship = inherit_field(USER_FIELDS["citizenship"], required=False) + designation = inherit_field(USER_FIELDS["designation"], required=False) + date_latest_training = inherit_field( + USER_FIELDS["date_latest_training"], required=False ) diff --git a/atst/forms/new_request.py b/atst/forms/new_request.py index 08d2fee4..8c96ab4a 100644 --- a/atst/forms/new_request.py +++ b/atst/forms/new_request.py @@ -1,17 +1,18 @@ import pendulum -from wtforms.fields.html5 import DateField, EmailField, IntegerField, TelField +from wtforms.fields.html5 import DateField, EmailField, IntegerField from wtforms.fields import BooleanField, RadioField, StringField, TextAreaField from wtforms.validators import Email, Length, Optional, InputRequired, DataRequired from .fields import SelectField from .forms import ValidatedForm +from .edit_user import USER_FIELDS, inherit_field from .data import ( SERVICE_BRANCHES, ASSISTANCE_ORG_TYPES, DATA_TRANSFER_AMOUNTS, COMPLETION_DATE_RANGES, ) -from .validators import Alphabet, DateRange, PhoneNumber, IsNumber +from .validators import DateRange, IsNumber from atst.domain.requests import Requests @@ -162,58 +163,21 @@ class DetailsOfUseForm(ValidatedForm): class InformationAboutYouForm(ValidatedForm): - fname_request = StringField("First Name", validators=[InputRequired(), Alphabet()]) + fname_request = inherit_field(USER_FIELDS["first_name"]) - lname_request = StringField("Last Name", validators=[InputRequired(), Alphabet()]) + lname_request = inherit_field(USER_FIELDS["last_name"]) email_request = EmailField("E-mail Address", validators=[InputRequired(), Email()]) - phone_number = TelField( - "Phone Number", - description="Enter a 10-digit phone number", - validators=[InputRequired(), PhoneNumber()], - ) + phone_number = inherit_field(USER_FIELDS["phone_number"]) - service_branch = SelectField( - "Service Branch or Agency", - description="Which service or organization do you belong to within the DoD?", - choices=SERVICE_BRANCHES, - ) + service_branch = inherit_field(USER_FIELDS["service_branch"]) - citizenship = RadioField( - description="What is your citizenship status?", - choices=[ - ("United States", "United States"), - ("Foreign National", "Foreign National"), - ("Other", "Other"), - ], - validators=[InputRequired()], - ) + citizenship = inherit_field(USER_FIELDS["citizenship"]) - designation = RadioField( - "Designation of Person", - description="What is your designation within the DoD?", - choices=[ - ("military", "Military"), - ("civilian", "Civilian"), - ("contractor", "Contractor"), - ], - validators=[InputRequired()], - ) + designation = inherit_field(USER_FIELDS["designation"]) - date_latest_training = DateField( - "Latest Information Assurance (IA) Training Completion Date", - description='To complete the training, you can find it in Information Assurance Cyber Awareness Challange website.', - validators=[ - InputRequired(), - DateRange( - lower_bound=pendulum.duration(years=1), - upper_bound=pendulum.duration(days=0), - message="Must be a date within the last year.", - ), - ], - format="%m/%d/%Y", - ) + date_latest_training = inherit_field(USER_FIELDS["date_latest_training"]) class WorkspaceOwnerForm(ValidatedForm): diff --git a/atst/forms/validators.py b/atst/forms/validators.py index 9b094d1c..142d55d1 100644 --- a/atst/forms/validators.py +++ b/atst/forms/validators.py @@ -6,6 +6,9 @@ from datetime import datetime def DateRange(lower_bound=None, upper_bound=None, message=None): def _date_range(form, field): + if field.data is None: + return + now = pendulum.now().date() if isinstance(field.data, str): diff --git a/atst/models/user.py b/atst/models/user.py index 48570937..bb89c51b 100644 --- a/atst/models/user.py +++ b/atst/models/user.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, ForeignKey, Column +from sqlalchemy import String, ForeignKey, Column, Date from sqlalchemy.orm import relationship from sqlalchemy.dialects.postgresql import UUID @@ -20,6 +20,11 @@ class User(Base, mixins.TimestampsMixin, mixins.AuditableMixin): dod_id = Column(String, unique=True, nullable=False) first_name = Column(String) last_name = Column(String) + phone_number = Column(String) + service_branch = Column(String) + citizenship = Column(String) + designation = Column(String) + date_latest_training = Column(Date) @property def atat_permissions(self): @@ -52,3 +57,10 @@ class User(Base, mixins.TimestampsMixin, mixins.AuditableMixin): self.has_workspaces, self.id, ) + + def to_dictionary(self): + return { + c.name: getattr(self, c.name) + for c in self.__table__.columns + if c.name not in ["id"] + } diff --git a/atst/routes/__init__.py b/atst/routes/__init__.py index c4b76de3..5051ad5a 100644 --- a/atst/routes/__init__.py +++ b/atst/routes/__init__.py @@ -10,7 +10,6 @@ from atst.domain.users import Users from atst.domain.authnid import AuthenticationContext from atst.domain.audit_log import AuditLog from atst.domain.auth import logout as _logout -from atst.forms.edit_user import EditUserForm bp = Blueprint("atst", __name__) @@ -119,19 +118,6 @@ def activity_history(): return render_template("audit_log.html", audit_events=audit_events) -@bp.route("/user") -def user(): - form = EditUserForm(request.form) - user = g.current_user - return render_template("user/edit.html", form=form, user=user) - - -@bp.route("/save_user") -def save_user(): - # no op - return redirect(url_for(".home")) - - @bp.route("/about") def about(): return render_template("about.html") diff --git a/atst/routes/users.py b/atst/routes/users.py new file mode 100644 index 00000000..e57584a7 --- /dev/null +++ b/atst/routes/users.py @@ -0,0 +1,25 @@ +from flask import Blueprint, render_template, g, request as http_request +from atst.forms.edit_user import EditUserForm +from atst.domain.users import Users + + +bp = Blueprint("users", __name__) + + +@bp.route("/user") +def user(): + user = g.current_user + form = EditUserForm(data=user.to_dictionary()) + return render_template("user/edit.html", form=form, user=user) + + +@bp.route("/user", methods=["POST"]) +def update_user(): + user = g.current_user + form = EditUserForm(http_request.form) + rerender_args = {"form": form, "user": user} + if form.validate(): + Users.update(user, form.data) + rerender_args["updated"] = True + + return render_template("user/edit.html", **rerender_args) diff --git a/templates/base_public.html b/templates/base_public.html index 006b04e5..f444dff8 100644 --- a/templates/base_public.html +++ b/templates/base_public.html @@ -26,7 +26,7 @@ {% if g.current_user %} - + diff --git a/templates/fragments/edit_user_form.html b/templates/fragments/edit_user_form.html index ed0ec406..1e7f3fe2 100644 --- a/templates/fragments/edit_user_form.html +++ b/templates/fragments/edit_user_form.html @@ -2,7 +2,8 @@ {% from "components/options_input.html" import OptionsInput %} {% from "components/date_input.html" import DateInput %} - {{ Icon('avatar', classes='topbar__link-icon') }}