Support adding/updating enrollment

This completes the enrollment flow.
This commit is contained in:
Dustin J. Mitchell 2021-02-04 00:05:22 +00:00 коммит произвёл Dustin J. Mitchell
Родитель e3a68dbe75
Коммит 9f11ae3ebb
14 изменённых файлов: 485 добавлений и 33 удалений

Просмотреть файл

@ -44,8 +44,8 @@ export default function AvailabilitySelector({ timeAvailability, onChange }) {
// if null or invalid, treat as 9-5 local time
if (timeAvailability === null || timeAvailability.length !== 24) {
timeAvailability = localToUtc('NNNNNNNNNYYYYYYYYNNNNNNN');
onChange(timeAvailability);
console.error(`Invalid timeAvailability value ${timeAvailability}; substituting default`);
timeAvailability = AvailabilitySelector.default();
}
// translate the availability to local time by rotating the string
@ -118,3 +118,7 @@ AvailabilitySelector.propTypes = {
onChange: propTypes.func.isRequired,
};
// Get a default value for this component (which depends on the timezon)
AvailabilitySelector.default = () => {
return localToUtc('NNNNNNNNNYYYYYYYYNNNNNNN');
}

Просмотреть файл

@ -18,9 +18,29 @@ export function useParticipantByEmail(email) {
return [{ loading, error, participant }];
}
// Return [{loading, error}, postParticipant] where postParticipant() will
// asynchronously post that partcipant to the backend. If `participant.id` is
// set, then the participant will be updated; otherwise, it will be created.
export function usePostParticipant(participant) {
const {id, ...data} = participant;
return useAxios({
...(id === undefined ? {
// create a participant
url: '/api/participants',
method: 'POST',
} : {
// update an existing participant
url: `/api/participants/${id}`,
method: 'PUT',
}),
data,
headers: { 'X-CSRFToken': MENTORING_SETTINGS.csrftoken },
}, { manual: true });
}
// a propTypes shape describing the data expected from the API
export const participantType = propTypes.shape({
id: propTypes.number.isRequired,
id: propTypes.number,
full_name: propTypes.string.isRequired,
email: propTypes.string.isRequired,
is_mentor: propTypes.bool.isRequired,

Просмотреть файл

@ -1,5 +1,5 @@
import { renderHook } from '@testing-library/react-hooks'
import { useParticipants, useParticipantByEmail } from './participants';
import { renderHook, act } from '@testing-library/react-hooks'
import { useParticipants, useParticipantByEmail, usePostParticipant } from './participants';
import { api } from '../../test/helper';
jest.mock('axios-hooks');
@ -46,3 +46,37 @@ describe('useParticipantByEmail', () => {
expect(result.current[0].participant).toBeFalsy();
});
});
describe('usePostParticipant', () => {
test('create', async () => {
let posted;
api.mock(api.onCreateParticipant(arg => { posted = arg; }));
const { result } = renderHook(() => usePostParticipant({
full_name: 'Averill',
}));
const [participant, postParticipant] = result.current;
// nothing has happened yet
expect(participant.loading).toBeFalsy();
expect(posted).toBeFalsy();
act(() => postParticipant());
});
test('update', async () => {
let posted;
api.mock(api.onUpdateParticipant(13, arg => { posted = arg; }));
const { result } = renderHook(() => usePostParticipant({
id: 13,
full_name: 'Averill',
}));
const [participant, postParticipant] = result.current;
// nothing has happened yet
expect(participant.loading).toBeFalsy();
expect(posted).toBeFalsy();
act(() => postParticipant());
});
});

Просмотреть файл

@ -15,6 +15,7 @@ import FormControlLabel from '@material-ui/core/FormControlLabel';
import FormHelperText from '@material-ui/core/FormHelperText';
import Switch from '@material-ui/core/Switch';
import Typography from '@material-ui/core/Typography';
import CircularProgress from '@material-ui/core/CircularProgress';
import InterestsControl from '../../components/InterestsControl';
import AvailabilitySelector from '../../components/AvailabilitySelector';
import { participantType } from '../../data/participants';
@ -24,6 +25,16 @@ const useStyles = makeStyles(theme => ({
maxWidth: "80em",
margin: theme.spacing(2),
},
buttonWrapper: {
position: 'relative',
},
buttonProgress: {
position: 'absolute',
top: '50%',
left: '50%',
marginTop: -12,
marginLeft: -12,
},
}));
const MENTORING_MANA_PAGE = "https://mana.mozilla.org/wiki/pages/viewpage.action?spaceKey=PR&title=Mozilla+Mentoring+Program";
@ -66,7 +77,7 @@ InfoLink.propTypes = {
children: propTypes.node.isRequired,
};
export default function EnrollmentForm({ participant, update, onParticipantChange, onSubmit }) {
export default function EnrollmentForm({ participant, update, onParticipantChange, onSubmit, submitLoading }) {
const classes = useStyles();
const mentor = participant.is_mentor;
@ -241,6 +252,7 @@ export default function EnrollmentForm({ participant, update, onParticipantChang
{...textFieldProps('track_change')}
label="Track Change"
disabled={!learner}
required={learner}
select
helperText="Are you considering change track (such as between IC and management)?" >
{menuItems(TRACK_CHANGE_INTEREST)}
@ -276,7 +288,12 @@ export default function EnrollmentForm({ participant, update, onParticipantChang
</Grid>
</CardContent>
<CardActions>
<Button type="submit" color="primary">{either ? (update ? "Update" : "Enroll") : "Leave"}</Button>
<div className={classes.buttonWrapper}>
<Button disabled={submitLoading} type="submit" color="primary">
{either ? (update ? "Update" : "Enroll") : "Leave"}
</Button>
{submitLoading && <CircularProgress size={24} className={classes.buttonProgress} />}
</div>
</CardActions>
</Card>
</form>
@ -289,4 +306,5 @@ EnrollmentForm.propTypes = {
update: propTypes.bool,
onParticipantChange: propTypes.func.isRequired,
onSubmit: propTypes.func.isRequired,
submitLoading: propTypes.bool.isRequired,
};

Просмотреть файл

@ -0,0 +1,37 @@
import React from "react";
import { useHistory } from "react-router-dom";
import Dialog from '@material-ui/core/Dialog';
import Button from '@material-ui/core/Button';
import DialogTitle from '@material-ui/core/DialogTitle';
import DialogContent from '@material-ui/core/DialogContent';
import DialogContentText from '@material-ui/core/DialogContentText';
import DialogActions from '@material-ui/core/DialogActions';
export default function PostedDialog() {
const history = useHistory();
const goHome = () => history.push("/");
return (
<div>
<Dialog
open
onClose={goHome}
aria-labelledby="posted-title"
aria-describedby="posted-desc">
<>
<DialogTitle id="posted-title">Responses Submitted</DialogTitle>
<DialogContent>
<DialogContentText id="posted-desc">
Thank you!
Your responses have been submitted.
You can expect to hear from the mentoring committee soon.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={goHome} color="primary">OK</Button>
</DialogActions>
</>
</Dialog>
</div>
);
}

Просмотреть файл

@ -3,9 +3,11 @@ import propTypes from 'prop-types';
import Helmet from 'react-helmet';
import Grid from '@material-ui/core/Grid';
import { useParticipantByEmail } from '../../data/participants';
import AvailabilitySelector from '../../components/AvailabilitySelector';
import Loading from '../../components/Loading';
import EnrollmentForm from './EnrollmentForm';
import { participantType } from '../../data/participants';
import PostedDialog from './PostedDialog';
import { participantType, usePostParticipant } from '../../data/participants';
export default function Enrollment({ role, update }) {
const { user } = MENTORING_SETTINGS;
@ -13,20 +15,20 @@ export default function Enrollment({ role, update }) {
// load the participant's enrollment from the API, if present
const [existing] = useParticipantByEmail(user.email);
const initialParticipant = existing.participant ? existing.participant : {
id: 0,
// no `id` property, meaning a new participant
full_name: `${user.first_name} ${user.last_name}`.trim(),
email: user.email,
manager: '',
manager_email: '',
is_mentor: role === 'mentor',
is_learner: role === 'learner' || update, // default this to true for updates, just in case
time_availability: 'NNNNNNNNNNNNNNNNNNNNNNNN',
is_learner: role === 'learner' || Boolean(update),
time_availability: AvailabilitySelector.default(),
org: '',
org_level: '',
time_at_org_level: '',
learner_interests: [],
mentor_interests: [],
track_change: '',
track_change: 'maybe', // default for mentors, since the choice is disabled
org_chart_distance: '',
comments: '',
};
@ -47,26 +49,44 @@ Enrollment.propTypes = {
// participant is loaded.
function WithLoadedParticipant({ update, initialParticipant }) {
const [participant, setParticipant] = useState(initialParticipant);
const [{loading: postLoading, error: postError}, postParticipant] = usePostParticipant(participant);
const [posted, setPosted] = useState(false);
const handleSubmit = event => {
console.log(participant);
postParticipant().then(
() => setPosted(true),
// errors are reported via postError, so ignore them here
() => {});
event.preventDefault();
};
if (postError) {
return (
<div>
<h2>Error</h2>
<pre>{postError.toString()}</pre>
</div>
);
}
return (
<Fragment>
<Helmet>
<title>Mozilla Mentorship Program - Enrollment</title>
</Helmet>
<Grid container justify="center">
<Grid item>
<EnrollmentForm
update={update}
participant={participant}
onParticipantChange={setParticipant}
onSubmit={handleSubmit} />
{posted ? <PostedDialog /> : (
<Grid container justify="center">
<Grid item>
<EnrollmentForm
update={update}
participant={participant}
onParticipantChange={setParticipant}
onSubmit={handleSubmit}
submitLoading={postLoading}
/>
</Grid>
</Grid>
</Grid>
)}
</Fragment>
);
}

Просмотреть файл

@ -66,4 +66,24 @@ export const api = {
implementation: [ { loading: false }, cb ],
};
},
/**
* Call the given callback when a participant is posted (created)
*/
onCreateParticipant(cb) {
return {
config: { method: 'POST', url: '/api/participants' },
implementation: [ { loading: false }, cb ],
};
},
/**
* Call the given callback when a participant is PUT (updated).
*/
onUpdateParticipant(id, cb) {
return {
config: { method: 'PUT', url: `/api/participants/${id}` },
implementation: [ { loading: false }, cb ],
};
},
}

Просмотреть файл

@ -22,3 +22,11 @@ class PairSerializer(serializers.HyperlinkedModelSerializer):
class PairViewSet(mixins.CreateModelMixin, viewsets.ReadOnlyModelViewSet):
queryset = Pair.objects.all()
serializer_class = PairSerializer
def perform_create(self, serializer):
serializer.save()
# bump expiration for the participants involved
for participant in (serializer.validated_data[k] for k in ['learner', 'mentor']):
participant.bump_expiration()
participant.save()

Просмотреть файл

@ -72,6 +72,10 @@ class PairTest(TestCase):
self.assertEqual(pair.mentor.id, mentor.id)
self.assertEqual(pair.learner.id, learner.id)
# check that expirations have been bumped
self.assertGreater(pair.mentor.expires, datetime.datetime.now(pytz.UTC))
self.assertGreater(pair.learner.expires, datetime.datetime.now(pytz.UTC))
def test_make_pair_rest_mentor_as_learner(self):
learner, mentor = self.make_particips()

Просмотреть файл

@ -0,0 +1,26 @@
# Generated by Django 3.1.2 on 2021-02-08 20:37
from django.db import migrations, models
from textwrap import dedent
from mentoring.participants.models import current_expiration
class Migration(migrations.Migration):
dependencies = [
('participants', '0003_auto_20210125_1622'),
]
operations = [
migrations.AlterField(
model_name='participant',
name='expires',
field=models.DateTimeField(
default=current_expiration, help_text=dedent('''\
The date that this information expires. This can be extended (such as when
a pairing is made), and expiration is contingent on not being in a current
pair. This field accomplishes the "lean data" practice of not keeping
user information forever. ''')),
),
]

Просмотреть файл

@ -1,4 +1,8 @@
import pytz
import datetime
from django.db import models
from django.conf import settings
from textwrap import dedent
from django.core.exceptions import ValidationError
@ -18,6 +22,11 @@ def validate_interests(interests):
raise ValidationError('interests must contain strings')
def current_expiration():
"""Return an appropriate expiration date for a participant updated today."""
return datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=settings.DATA_RETENTION_DAYS)
class Participant(models.Model):
"""
A Participant in the program.
@ -26,11 +35,14 @@ class Participant(models.Model):
def __str__(self):
return f'{self.full_name}'
expires = models.DateTimeField(null=False, help_text=dedent('''\
The date that this information expires. This can be extended (such as when
a pairing is made), and expiration is contingent on not being in a current
pair. This field accomplishes the "lean data" practice of not keeping
user information forever. '''))
expires = models.DateTimeField(
null=False,
default=current_expiration,
help_text=dedent('''\
The date that this information expires. This can be extended (such as when
a pairing is made), and expiration is contingent on not being in a current
pair. This field accomplishes the "lean data" practice of not keeping
user information forever. '''))
email = models.EmailField(null=False, unique=True, help_text=dedent('''\
The participant's work email address. This is used as a key. '''))
@ -104,5 +116,10 @@ class Participant(models.Model):
help_text=dedent('''Open comments from the participant's enrollment'''),
)
def bump_expiration(self):
"""Bump the expiration time for this participant, such as when making a substantive
change to the participant's record."""
self.expires = current_expiration()
class Meta:
db_table = "participants"

Просмотреть файл

@ -1,8 +1,8 @@
from rest_framework import serializers, viewsets, permissions, decorators, status
from rest_framework import serializers, viewsets, permissions, decorators, status, mixins, exceptions
from rest_framework.response import Response
from django.shortcuts import get_object_or_404
from .models import Participant
from .models import Participant, current_expiration
class ParticipantSerializer(serializers.HyperlinkedModelSerializer):
@ -28,12 +28,50 @@ class ParticipantSerializer(serializers.HyperlinkedModelSerializer):
'comments',
]
def validate(self, data):
"""Require that the `email` property, if given, matches the user during deserialization"""
if not self.context or "request" not in self.context:
raise serializers.ValidationError("validation requires request context")
request = self.context['request']
# ViewSets define the view behavior.
class ParticipantViewSet(viewsets.ReadOnlyModelViewSet):
if 'email' in data and data['email'] != request.user.email:
raise serializers.ValidationError({"email": "email field must match your own email"})
return data
class IsParticipantOrAdminUser(permissions.BasePermission):
"""
Object-level permission to allow users to access their own participant record.
"""
# note that this cannot be represented as IsParticipant | IsAdminUser, as the
# IsAdminUser class is a view-level permission
def has_object_permission(self, request, view, obj):
if request.user.is_staff:
return True
if request.user.is_anonymous:
return False
return obj.email == request.user.email
class ParticipantViewSet(
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
viewsets.ReadOnlyModelViewSet):
queryset = Participant.objects.all()
serializer_class = ParticipantSerializer
def get_permissions(self):
# non-admin users cannot list anything..
if self.action == 'list':
permission_classes = [permissions.IsAdminUser]
else:
# ..but can read, create, and update (and by_email) their own user
permission_classes = [IsParticipantOrAdminUser]
return [permission() for permission in permission_classes]
@decorators.action(detail=False, url_path='by_email')
def by_email(self, request, pk=None):
"""Get a participant by their email address, using `?email=..`"""
@ -41,5 +79,10 @@ class ParticipantViewSet(viewsets.ReadOnlyModelViewSet):
if email is None:
return Response("Missing `email` query parameter", status=status.HTTP_400_BAD_REQUEST)
particip = get_object_or_404(Participant, email=email)
self.check_object_permissions(self.request, particip)
data = self.serializer_class(particip).data
return Response(data, status=status.HTTP_200_OK)
def perform_update(self, serializer):
# on every update, bump the expiration time
serializer.save(expires=current_expiration())

Просмотреть файл

@ -1,3 +1,204 @@
from django.test import TestCase
import pytz
import datetime
# Create your tests here.
from django.test import TestCase
from django.contrib.auth.models import User
from rest_framework.test import APIClient
from .models import Participant
from .rest import ParticipantSerializer
def make_particip(no_save=False):
particip = Participant(
expires=datetime.datetime.now(pytz.UTC),
email='llearner@mozilla.com',
is_learner=True,
is_mentor=False,
full_name='Logan Learner',
manager='Mani Shur',
manager_email='mshur@mozilla.com',
time_availability='N' * 24,
)
if not no_save:
particip.save()
return particip
def login(client, email, admin=False):
if admin:
user = User.objects.create_superuser('particip', email=email)
else:
user = User.objects.create_user('particip', email=email)
user.save()
client.force_authenticate(user=user)
class ParticipantTestAnonumous(TestCase):
def test_api_list_anonymous(self):
particip = make_particip()
client = APIClient()
res = client.get('/api/participants')
self.assertEqual(res.status_code, 403)
def test_api_get_anonymous(self):
particip = make_particip()
client = APIClient()
res = client.get(f'/api/participants/{particip.id}')
self.assertEqual(res.status_code, 403)
def test_api_get_by_email_anonymous(self):
particip = make_particip()
client = APIClient()
res = client.get(f'/api/participants/by_email?email={particip.email}')
self.assertEqual(res.status_code, 403)
class ParticipantTestRegularUser(TestCase):
def test_api_list_user(self):
particip = make_particip()
client = APIClient()
login(client, particip.email)
res = client.get('/api/participants')
self.assertEqual(res.status_code, 403)
def test_api_get_user_not_self(self):
particip = make_particip()
client = APIClient()
login(client, 'someone-else@mozilla.com')
res = client.get(f'/api/participants/{particip.id}')
self.assertEqual(res.status_code, 403)
def test_api_get_user_self(self):
particip = make_particip()
client = APIClient()
login(client, particip.email)
res = client.get(f'/api/participants/{particip.id}')
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json()['email'], particip.email)
def test_api_get_user_by_email_not_self(self):
particip = make_particip()
client = APIClient()
login(client, 'someone-else@mozilla.com')
res = client.get(f'/api/participants/by_email?email={particip.email}')
self.assertEqual(res.status_code, 403)
def test_api_get_user_by_email_self(self):
particip = make_particip()
client = APIClient()
login(client, particip.email)
res = client.get(f'/api/participants/by_email?email={particip.email}')
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json()['email'], particip.email)
def test_api_update_user_not_self(self):
particip = make_particip()
client = APIClient()
login(client, 'someone-else@mozilla.com')
res = client.put(
f'/api/participants/{particip.id}',
{})
self.assertEqual(res.status_code, 403)
def test_api_update_user_self(self):
particip = make_particip()
particip_json = ParticipantSerializer(particip).data
particip_json['full_name'] = 'UPDATED'
client = APIClient()
login(client, particip.email)
res = client.put(
f'/api/participants/{particip.id}',
particip_json,
format='json')
self.assertEqual(res.status_code, 200)
res = client.get(f'/api/participants/{particip.id}')
self.assertEqual(res.json()['full_name'], 'UPDATED')
def test_api_partial_update_user_self(self):
particip = make_particip()
client = APIClient()
login(client, particip.email)
res = client.patch(
f'/api/participants/{particip.id}',
{'full_name': 'UPDATED'},
format='json')
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json()["full_name"], "UPDATED")
def test_api_update_user_self_change_email(self):
particip = make_particip()
particip_json = ParticipantSerializer(particip).data
particip_json['email'] = 'UPDATED@mozilla.com'
client = APIClient()
login(client, particip.email)
res = client.put(
f'/api/participants/{particip.id}',
particip_json,
format='json')
self.assertEqual(res.status_code, 400)
self.assertEqual(res.json(), {"email": ["email field must match your own email"]})
def test_api_partial_update_user_self_change_email(self):
particip = make_particip()
client = APIClient()
login(client, particip.email)
res = client.patch(
f'/api/participants/{particip.id}',
{'email': 'UPDATED@mozilla.com'},
format='json')
self.assertEqual(res.status_code, 400)
self.assertEqual(res.json(), {"email": ["email field must match your own email"]})
def test_api_create_user_self(self):
particip = make_particip(no_save=True)
particip_json = ParticipantSerializer(particip).data
client = APIClient()
login(client, particip.email)
res = client.post(
'/api/participants',
particip_json,
format='json')
self.assertEqual(res.status_code, 201)
self.assertEqual(res.json()['email'], particip.email)
def test_api_create_user_not_self(self):
particip = make_particip(no_save=True)
particip_json = ParticipantSerializer(particip).data
client = APIClient()
login(client, 'someone-else@mozilla.com')
res = client.post(
'/api/participants',
particip_json,
format='json')
self.assertEqual(res.status_code, 400)
self.assertEqual(res.json(), {"email": ["email field must match your own email"]})
class ParticipantTestAdminUser(TestCase):
def test_api_list_admin(self):
particip = make_particip()
client = APIClient()
login(client, particip.email, admin=True)
res = client.get('/api/participants')
self.assertEqual(res.status_code, 200)
self.assertEqual([r['email'] for r in res.json()], [particip.email])
def test_api_get_admin(self):
particip = make_particip()
client = APIClient()
login(client, particip.email, admin=True)
res = client.get(f'/api/participants/{particip.id}')
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json()['email'], particip.email)
def test_api_get_by_email_admin(self):
particip = make_particip()
client = APIClient()
login(client, particip.email, admin=True)
res = client.get(f'/api/participants/by_email?email={particip.email}')
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json()['email'], particip.email)

Просмотреть файл

@ -1,6 +1,6 @@
asgiref==3.2.10
Django==3.1.2
djangorestframework==3.12.1
djangorestframework==3.12.2
django-csp==3.7
pytz==2020.1
sqlparse==0.4.1