Edit user #160268937
This commit is contained in:
dandds 2018-10-17 15:04:15 -04:00 committed by GitHub
commit b733884c30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 238 additions and 108 deletions

View File

@ -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 ###

View File

@ -15,6 +15,7 @@ from atst.routes import bp
from atst.routes.workspaces import bp as workspace_routes from atst.routes.workspaces import bp as workspace_routes
from atst.routes.requests import requests_bp from atst.routes.requests import requests_bp
from atst.routes.dev import bp as dev_routes 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.routes.errors import make_error_pages
from atst.domain.authnid.crl import CRLCache from atst.domain.authnid.crl import CRLCache
from atst.domain.auth import apply_authentication from atst.domain.auth import apply_authentication
@ -57,6 +58,7 @@ def make_app(config):
app.register_blueprint(bp) app.register_blueprint(bp)
app.register_blueprint(workspace_routes) app.register_blueprint(workspace_routes)
app.register_blueprint(requests_bp) app.register_blueprint(requests_bp)
app.register_blueprint(user_routes)
if ENV != "prod": if ENV != "prod":
app.register_blueprint(dev_routes) app.register_blueprint(dev_routes)

View File

@ -5,7 +5,7 @@ from atst.database import db
from atst.models import User from atst.models import User
from .roles import Roles from .roles import Roles
from .exceptions import NotFoundError, AlreadyExistsError from .exceptions import NotFoundError, AlreadyExistsError, UnauthorizedError
class Users(object): class Users(object):
@ -53,7 +53,7 @@ class Users(object):
return user return user
@classmethod @classmethod
def update(cls, user_id, atat_role_name): def update_role(cls, user_id, atat_role_name):
user = Users.get(user_id) user = Users.get(user_id)
atat_role = Roles.get(atat_role_name) atat_role = Roles.get(atat_role_name)
@ -63,3 +63,29 @@ class Users(object):
db.session.commit() db.session.commit()
return user 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

View File

@ -1,7 +1,7 @@
from atst.domain.roles import WORKSPACE_ROLES as WORKSPACE_ROLE_DEFINITIONS from atst.domain.roles import WORKSPACE_ROLES as WORKSPACE_ROLE_DEFINITIONS
SERVICE_BRANCHES = [ SERVICE_BRANCHES = [
(None, "Select an option"), ("", "Select an option"),
("Air Force, Department of the", "Air Force, Department of the"), ("Air Force, Department of the", "Air Force, Department of the"),
("Army and Air Force Exchange Service", "Army and Air Force Exchange Service"), ("Army and Air Force Exchange Service", "Army and Air Force Exchange Service"),
("Army, Department of the", "Army, Department of the"), ("Army, Department of the", "Army, Department of the"),

View File

@ -1,7 +1,8 @@
import pendulum import pendulum
from copy import deepcopy
from wtforms.fields.html5 import DateField, EmailField, TelField from wtforms.fields.html5 import DateField, EmailField, TelField
from wtforms.fields import RadioField, StringField from wtforms.fields import RadioField, StringField
from wtforms.validators import Email, Required from wtforms.validators import Email, Required, Optional
from .fields import SelectField from .fields import SelectField
from .forms import ValidatedForm from .forms import ValidatedForm
@ -9,42 +10,33 @@ from .data import SERVICE_BRANCHES
from .validators import Alphabet, DateRange, PhoneNumber from .validators import Alphabet, DateRange, PhoneNumber
USER_FIELDS = {
class EditUserForm(ValidatedForm): "first_name": StringField("First Name", validators=[Alphabet()]),
"last_name": StringField("Last Name", validators=[Alphabet()]),
first_name = StringField("First Name", validators=[Required(), Alphabet()]) "email": EmailField(
last_name = StringField("Last Name", validators=[Required(), Alphabet()])
email = EmailField(
"E-mail Address", "E-mail Address",
description="Enter your preferred contact e-mail address", description="Enter your preferred contact e-mail address",
validators=[Required(), Email()], validators=[Email()],
) ),
"phone_number": TelField(
phone_number = TelField(
"Phone Number", "Phone Number",
description="Enter your 10-digit U.S. phone number", description="Enter your 10-digit U.S. phone number",
validators=[Required(), PhoneNumber()], validators=[PhoneNumber()],
) ),
"service_branch": SelectField(
service_branch = SelectField(
"Service Branch or Agency", "Service Branch or Agency",
description="Which service or organization do you belong to within the DoD?", description="Which service or organization do you belong to within the DoD?",
choices=SERVICE_BRANCHES, choices=SERVICE_BRANCHES,
) ),
"citizenship": RadioField(
citizenship = RadioField(
description="What is your citizenship status?", description="What is your citizenship status?",
choices=[ choices=[
("United States", "United States"), ("United States", "United States"),
("Foreign National", "Foreign National"), ("Foreign National", "Foreign National"),
("Other", "Other"), ("Other", "Other"),
], ],
validators=[Required()], ),
) "designation": RadioField(
designation = RadioField(
"Designation of Person", "Designation of Person",
description="What is your designation within the DoD?", description="What is your designation within the DoD?",
choices=[ choices=[
@ -52,19 +44,43 @@ class EditUserForm(ValidatedForm):
("civilian", "Civilian"), ("civilian", "Civilian"),
("contractor", "Contractor"), ("contractor", "Contractor"),
], ],
validators=[Required()], ),
) "date_latest_training": DateField(
date_latest_training = DateField(
"Latest Information Assurance (IA) Training Completion Date", "Latest Information Assurance (IA) Training Completion Date",
description='To complete the training, you can find it in <a class="icon-link" href="https://iatraining.disa.mil/eta/disa_cac2018/launchPage.htm" target="_blank">Information Assurance Cyber Awareness Challange</a> website.', description='To complete the training, you can find it in <a class="icon-link" href="https://iatraining.disa.mil/eta/disa_cac2018/launchPage.htm" target="_blank">Information Assurance Cyber Awareness Challange</a> website.',
validators=[ validators=[
Required(),
DateRange( DateRange(
lower_bound=pendulum.duration(years=1), lower_bound=pendulum.duration(years=1),
upper_bound=pendulum.duration(days=0), upper_bound=pendulum.duration(days=0),
message="Must be a date within the last year.", message="Must be a date within the last year.",
), )
], ],
format="%m/%d/%Y", 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
) )

View File

@ -1,17 +1,18 @@
import pendulum 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.fields import BooleanField, RadioField, StringField, TextAreaField
from wtforms.validators import Email, Length, Optional, InputRequired, DataRequired from wtforms.validators import Email, Length, Optional, InputRequired, DataRequired
from .fields import SelectField from .fields import SelectField
from .forms import ValidatedForm from .forms import ValidatedForm
from .edit_user import USER_FIELDS, inherit_field
from .data import ( from .data import (
SERVICE_BRANCHES, SERVICE_BRANCHES,
ASSISTANCE_ORG_TYPES, ASSISTANCE_ORG_TYPES,
DATA_TRANSFER_AMOUNTS, DATA_TRANSFER_AMOUNTS,
COMPLETION_DATE_RANGES, COMPLETION_DATE_RANGES,
) )
from .validators import Alphabet, DateRange, PhoneNumber, IsNumber from .validators import DateRange, IsNumber
from atst.domain.requests import Requests from atst.domain.requests import Requests
@ -162,58 +163,21 @@ class DetailsOfUseForm(ValidatedForm):
class InformationAboutYouForm(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()]) email_request = EmailField("E-mail Address", validators=[InputRequired(), Email()])
phone_number = TelField( phone_number = inherit_field(USER_FIELDS["phone_number"])
"Phone Number",
description="Enter a 10-digit phone number",
validators=[InputRequired(), PhoneNumber()],
)
service_branch = SelectField( service_branch = inherit_field(USER_FIELDS["service_branch"])
"Service Branch or Agency",
description="Which service or organization do you belong to within the DoD?",
choices=SERVICE_BRANCHES,
)
citizenship = RadioField( citizenship = inherit_field(USER_FIELDS["citizenship"])
description="What is your citizenship status?",
choices=[
("United States", "United States"),
("Foreign National", "Foreign National"),
("Other", "Other"),
],
validators=[InputRequired()],
)
designation = RadioField( designation = inherit_field(USER_FIELDS["designation"])
"Designation of Person",
description="What is your designation within the DoD?",
choices=[
("military", "Military"),
("civilian", "Civilian"),
("contractor", "Contractor"),
],
validators=[InputRequired()],
)
date_latest_training = DateField( date_latest_training = inherit_field(USER_FIELDS["date_latest_training"])
"Latest Information Assurance (IA) Training Completion Date",
description='To complete the training, you can find it in <a class="icon-link" href="https://iatraining.disa.mil/eta/disa_cac2018/launchPage.htm" target="_blank">Information Assurance Cyber Awareness Challange</a> 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",
)
class WorkspaceOwnerForm(ValidatedForm): class WorkspaceOwnerForm(ValidatedForm):

View File

@ -6,6 +6,9 @@ from datetime import datetime
def DateRange(lower_bound=None, upper_bound=None, message=None): def DateRange(lower_bound=None, upper_bound=None, message=None):
def _date_range(form, field): def _date_range(form, field):
if field.data is None:
return
now = pendulum.now().date() now = pendulum.now().date()
if isinstance(field.data, str): if isinstance(field.data, str):

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, ForeignKey, Column from sqlalchemy import String, ForeignKey, Column, Date
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID 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) dod_id = Column(String, unique=True, nullable=False)
first_name = Column(String) first_name = Column(String)
last_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 @property
def atat_permissions(self): def atat_permissions(self):
@ -52,3 +57,10 @@ class User(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
self.has_workspaces, self.has_workspaces,
self.id, 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"]
}

View File

@ -10,7 +10,6 @@ from atst.domain.users import Users
from atst.domain.authnid import AuthenticationContext from atst.domain.authnid import AuthenticationContext
from atst.domain.audit_log import AuditLog from atst.domain.audit_log import AuditLog
from atst.domain.auth import logout as _logout from atst.domain.auth import logout as _logout
from atst.forms.edit_user import EditUserForm
bp = Blueprint("atst", __name__) bp = Blueprint("atst", __name__)
@ -119,19 +118,6 @@ def activity_history():
return render_template("audit_log.html", audit_events=audit_events) 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") @bp.route("/about")
def about(): def about():
return render_template("about.html") return render_template("about.html")

25
atst/routes/users.py Normal file
View File

@ -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)

View File

@ -26,7 +26,7 @@
</a> </a>
{% if g.current_user %} {% if g.current_user %}
<a href="{{ url_for('atst.user') }}" class="topbar__link"> <a href="{{ url_for('users.user') }}" class="topbar__link">
<span class="topbar__link-label">{{ g.current_user.first_name + " " + g.current_user.last_name }}</span> <span class="topbar__link-label">{{ g.current_user.first_name + " " + g.current_user.last_name }}</span>
{{ Icon('avatar', classes='topbar__link-icon') }} {{ Icon('avatar', classes='topbar__link-icon') }}
</a> </a>

View File

@ -2,7 +2,8 @@
{% from "components/options_input.html" import OptionsInput %} {% from "components/options_input.html" import OptionsInput %}
{% from "components/date_input.html" import DateInput %} {% from "components/date_input.html" import DateInput %}
<form action='{{ form_action }}'> <form method="POST" action='{{ form_action }}'>
{{ form.csrf_token }}
<div class='panel'> <div class='panel'>
<div class='panel__content'> <div class='panel__content'>
<div class='form-row'> <div class='form-row'>

View File

@ -20,7 +20,7 @@
</a> </a>
{% endif %} {% endif %}
<a href="{{ url_for('atst.user') }}" class="topbar__link"> <a href="{{ url_for('users.user') }}" class="topbar__link">
<span class="topbar__link-label">{{ g.current_user.first_name + " " + g.current_user.last_name }}</span> <span class="topbar__link-label">{{ g.current_user.first_name + " " + g.current_user.last_name }}</span>
{{ Icon('avatar', classes='topbar__link-icon') }} {{ Icon('avatar', classes='topbar__link-icon') }}
</a> </a>

View File

@ -3,21 +3,29 @@
{% block content %} {% block content %}
<div class='col'> <div class='col'>
{{ Alert('This form does not yet function',
message="<p>Functionality of this form is pending more engineering work. Engineers, please remove this alert when done.</p>", {% if form.errors %}
level='warning' {{ Alert('There were some errors',
) }} message="<p>Please see below.</p>",
level='error'
) }}
{% endif %}
{% if updated %}
{{ Alert('User information updated.', level='success') }}
{% endif %}
<div class='panel'> <div class='panel'>
<div class='panel__heading'> <div class='panel__heading'>
<h1> <h1>
<div class='h2'>{{ user.first_name }} {{ user.last_name }}</div> <div class='h2'>{{ user.first_name }} {{ user.last_name }}</div>
<div class='h3'>DOD ID: {{ user.dod_id }}</div>
<div class='subtitle h3'>Edit user details</div> <div class='subtitle h3'>Edit user details</div>
</h1> </h1>
</div> </div>
</div> </div>
{% set form_action = url_for('atst.save_user') %} {% set form_action = url_for('users.update_user') %}
{% include "fragments/edit_user_form.html" %} {% include "fragments/edit_user_form.html" %}
</div> </div>

View File

@ -2,7 +2,7 @@ import pytest
from uuid import uuid4 from uuid import uuid4
from atst.domain.users import Users from atst.domain.users import Users
from atst.domain.exceptions import NotFoundError, AlreadyExistsError from atst.domain.exceptions import NotFoundError, AlreadyExistsError, UnauthorizedError
DOD_ID = "my_dod_id" DOD_ID = "my_dod_id"
@ -52,20 +52,34 @@ def test_get_user_by_dod_id():
assert user == new_user assert user == new_user
def test_update_user(): def test_update_role():
new_user = Users.create(DOD_ID, "developer") new_user = Users.create(DOD_ID, "developer")
updated_user = Users.update(new_user.id, "ccpo") updated_user = Users.update_role(new_user.id, "ccpo")
assert updated_user.atat_role.name == "ccpo" assert updated_user.atat_role.name == "ccpo"
def test_update_nonexistent_user(): def test_update_role_with_nonexistent_user():
Users.create(DOD_ID, "developer") Users.create(DOD_ID, "developer")
with pytest.raises(NotFoundError): with pytest.raises(NotFoundError):
Users.update(uuid4(), "ccpo") Users.update_role(uuid4(), "ccpo")
def test_update_existing_user_with_nonexistent_role(): def test_update_existing_user_with_nonexistent_role():
new_user = Users.create(DOD_ID, "developer") new_user = Users.create(DOD_ID, "developer")
with pytest.raises(NotFoundError): with pytest.raises(NotFoundError):
Users.update(new_user.id, "nonexistent") Users.update_role(new_user.id, "nonexistent")
def test_update_user():
new_user = Users.create(DOD_ID, "developer")
updated_user = Users.update(new_user, {"first_name": "Jabba"})
assert updated_user.first_name == "Jabba"
def test_update_user_with_dod_id():
new_user = Users.create(DOD_ID, "developer")
with pytest.raises(UnauthorizedError) as excinfo:
Users.update(new_user, {"dod_id": "1234567890"})
assert "dod_id" in str(excinfo.value)

View File

@ -48,6 +48,18 @@ class UserFactory(Base):
last_name = factory.Faker("last_name") last_name = factory.Faker("last_name")
atat_role = factory.SubFactory(RoleFactory) atat_role = factory.SubFactory(RoleFactory)
dod_id = factory.LazyFunction(lambda: "".join(random.choices(string.digits, k=10))) dod_id = factory.LazyFunction(lambda: "".join(random.choices(string.digits, k=10)))
phone_number = factory.LazyFunction(
lambda: "".join(random.choices(string.digits, k=10))
)
service_branch = factory.LazyFunction(
lambda: random.choices([k for k, v in SERVICE_BRANCHES if k is not None])[0]
)
citizenship = "United States"
designation = "military"
date_latest_training = factory.LazyFunction(
lambda: datetime.date.today()
+ datetime.timedelta(days=-(random.randrange(1, 365)))
)
@classmethod @classmethod
def from_atat_role(cls, atat_role_name, **kwargs): def from_atat_role(cls, atat_role_name, **kwargs):

View File

@ -0,0 +1,25 @@
from flask import url_for
from atst.domain.users import Users
from tests.factories import UserFactory
def test_user_can_view_profile(user_session, client):
user = UserFactory.create()
user_session(user)
response = client.get(url_for("users.user"))
assert user.email in response.data.decode()
def test_user_can_update_profile(user_session, client):
user = UserFactory.create()
user_session(user)
new_data = {**user.to_dictionary(), "first_name": "chad", "last_name": "vader"}
new_data["date_latest_training"] = new_data["date_latest_training"].strftime(
"%m/%d/%Y"
)
client.post(url_for("users.update_user"), data=new_data)
updated_user = Users.get_by_dod_id(user.dod_id)
assert updated_user.first_name == "chad"
assert updated_user.last_name == "vader"