Add UI and API for expressing interest in a course

This commit is contained in:
Tanner Collin 2022-05-04 01:27:50 +00:00
parent 12e0e7441b
commit 29980025fb
7 changed files with 166 additions and 70 deletions

View File

@ -142,6 +142,13 @@ class Training(models.Model):
history = HistoricalRecords() history = HistoricalRecords()
class Interest(models.Model):
user = models.ForeignKey(User, related_name='interests', null=True, on_delete=models.SET_NULL)
course = models.ForeignKey(Course, related_name='courses', null=True, on_delete=models.SET_NULL)
satisfied_by = models.ForeignKey(Session, related_name='satisfies', null=True, on_delete=models.SET_NULL)
class MetaInfo(models.Model): class MetaInfo(models.Model):
backup_id = models.TextField() backup_id = models.TextField()

View File

@ -521,10 +521,18 @@ class UserTrainingSerializer(serializers.ModelSerializer):
exclude = ['user'] exclude = ['user']
depth = 2 depth = 2
class InterestSerializer(serializers.ModelSerializer):
course = serializers.PrimaryKeyRelatedField(queryset=models.Course.objects.all())
class Meta:
model = models.Interest
fields = '__all__'
read_only_fields = ['user', 'satisfied_by']
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
training = UserTrainingSerializer(many=True) training = UserTrainingSerializer(many=True)
member = MemberSerializer() member = MemberSerializer()
transactions = serializers.SerializerMethodField() transactions = serializers.SerializerMethodField()
interests = serializers.SerializerMethodField()
door_code = serializers.SerializerMethodField() door_code = serializers.SerializerMethodField()
wifi_pass = serializers.SerializerMethodField() wifi_pass = serializers.SerializerMethodField()
app_version = serializers.SerializerMethodField() app_version = serializers.SerializerMethodField()
@ -543,6 +551,7 @@ class UserSerializer(serializers.ModelSerializer):
'wifi_pass', 'wifi_pass',
'app_version', 'app_version',
#'usages', #'usages',
'interests',
] ]
depth = 1 depth = 1
@ -554,6 +563,13 @@ class UserSerializer(serializers.ModelSerializer):
serializer.is_valid() serializer.is_valid()
return serializer.data return serializer.data
def get_interests(self, obj):
interests = models.Interest.objects.filter(
user=obj,
satisfied_by__isnull=True
)
return [x.course.id for x in interests]
def get_door_code(self, obj): def get_door_code(self, obj):
if not obj.member.paused_date and obj.cards.count(): if not obj.member.paused_date and obj.cards.count():
return secrets.DOOR_CODE return secrets.DOOR_CODE

View File

@ -992,6 +992,24 @@ class UsageViewSet(Base):
return response return response
class InterestViewSet(Base, Retrieve, Create):
permission_classes = [AllowMetadata | IsAuthenticated]
queryset = models.Interest.objects.all()
serializer_class = serializers.InterestSerializer
def perform_create(self, serializer):
user = self.request.user
course = self.request.data['course']
interest = models.Interest.objects.filter(user=user, course=course, satisfied_by__isnull=True)
if interest.exists():
raise exceptions.ValidationError(dict(non_field_errors='Already interested'))
serializer.save(
user=user,
satisfied_by=None
)
class RegistrationView(RegisterView): class RegistrationView(RegisterView):
serializer_class = serializers.MyRegisterSerializer serializer_class = serializers.MyRegisterSerializer

View File

@ -21,6 +21,7 @@ router.register(r'history', views.HistoryViewSet, basename='history')
router.register(r'vetting', views.VettingViewSet, basename='vetting') router.register(r'vetting', views.VettingViewSet, basename='vetting')
router.register(r'sessions', views.SessionViewSet, basename='session') router.register(r'sessions', views.SessionViewSet, basename='session')
router.register(r'training', views.TrainingViewSet, basename='training') router.register(r'training', views.TrainingViewSet, basename='training')
router.register(r'interest', views.InterestViewSet, basename='interest')
router.register(r'transactions', views.TransactionViewSet, basename='transaction') router.register(r'transactions', views.TransactionViewSet, basename='transaction')
router.register(r'charts/membercount', views.MemberCountViewSet, basename='membercount') router.register(r'charts/membercount', views.MemberCountViewSet, basename='membercount')
router.register(r'charts/signupcount', views.SignupCountViewSet, basename='signupcount') router.register(r'charts/signupcount', views.SignupCountViewSet, basename='signupcount')

View File

@ -260,7 +260,7 @@ function App() {
</Route> </Route>
<Route exact path='/classes'> <Route exact path='/classes'>
<Classes token={token} user={user} /> <Classes token={token} user={user} refreshUser={refreshUser} />
</Route> </Route>
{user && user.member.set_details ? {user && user.member.set_details ?

View File

@ -98,49 +98,65 @@ function ClassTable(props) {
); );
}; };
function NewClassTable(props) { function NewClassTableCourse(props) {
const { classes, courses } = props; const {course, classes, token, user, refreshUser} = props;
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
let sortedClasses = []; const handleInterest = () => {
let seenCourseIds = []; if (loading) return;
if (classes.length) { setLoading(true);
for (const clazz of classes) { const data = { course: course.id };
const course_data = clazz.course_data; requester('/interest/', 'POST', token, data)
const course = sortedClasses.find(x => x?.course?.id === course_data?.id); .then(res => {
setError(false);
if (course) { refreshUser();
course.classes.push(clazz); })
} else { .catch(err => {
sortedClasses.push({ console.log(err);
course: course_data, setError(true);
classes: [clazz],
}); });
seenCourseIds.push( };
course_data.id
);
}
}
}
const now = new Date().toISOString(); const now = new Date().toISOString();
return ( return (
<>
<div className='newclasstable'>
{sortedClasses.map(x =>
<Segment style={{ margin: '1rem 1rem 0 0', width: '22rem' }}> <Segment style={{ margin: '1rem 1rem 0 0', width: '22rem' }}>
<Header size='small'> <Header size='small'>
<Link to={'/courses/'+x.course.id}> <Link to={'/courses/'+course.id}>
{x.course.name} {course.name}
</Link> </Link>
</Header> </Header>
{!!x.course.tags && x.course.tags.split(',').map(name => <div className='byline'>
<div className='tags'>
{!!course.tags && course.tags.split(',').map(name =>
<Label color={tags[name]} tag size='small'> <Label color={tags[name]} tag size='small'>
{name} {name}
</Label> </Label>
)} )}
</div>
{user &&
<div className='interest'>
{user.interests.includes(course.id) ?
'Interested ✅'
:
<Button
size='tiny'
loading={loading}
onClick={handleInterest}
>
Interest&nbsp;+
</Button>
}
</div>
}
</div>
{error && <p>Error.</p>}
{classes ?
<Table compact unstackable singleLine basic='very'> <Table compact unstackable singleLine basic='very'>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
@ -151,7 +167,7 @@ function NewClassTable(props) {
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{x.classes.map(x => {classes.map(x =>
<Table.Row key={x.id} active={x.datetime < now || x.is_cancelled}> <Table.Row key={x.id} active={x.datetime < now || x.is_cancelled}>
<Table.Cell> <Table.Cell>
<Link to={'/classes/'+x.id}> <Link to={'/classes/'+x.id}>
@ -176,26 +192,49 @@ function NewClassTable(props) {
)} )}
</Table.Body> </Table.Body>
</Table> </Table>
:
<>
<p/>
<p>No upcoming classes.</p>
</>
}
</Segment> </Segment>
);
}
function NewClassTable(props) {
const { classes, courses, token, user, refreshUser } = props;
let sortedClasses = [];
let seenCourseIds = [];
if (classes.length) {
for (const clazz of classes) {
const course_data = clazz.course_data;
const course = sortedClasses.find(x => x?.course?.id === course_data?.id);
if (course) {
course.classes.push(clazz);
} else {
sortedClasses.push({
course: course_data,
classes: [clazz],
});
seenCourseIds.push(
course_data.id
);
}
}
}
return (
<>
<div className='newclasstable'>
{sortedClasses.map(x =>
<NewClassTableCourse course={x.course} classes={x.classes} token={token} user={user} refreshUser={refreshUser} />
)} )}
{courses.filter(x => !seenCourseIds.includes(x.id)).map(x => {courses.filter(x => !seenCourseIds.includes(x.id)).map(x =>
<Segment style={{ margin: '1rem 1rem 0 0', width: '22rem' }}> <NewClassTableCourse course={x} classes={false} token={token} user={user} refreshUser={refreshUser} />
<Header size='small'>
<Link to={'/courses/'+x.id}>
{x.name}
</Link>
</Header>
{!!x.tags && x.tags.split(',').map(name =>
<Label color={tags[name]} tag>
{name}
</Label>
)}
<p/>
<p>No upcoming classes.</p>
</Segment>
)} )}
</div> </div>
</> </>
@ -248,7 +287,7 @@ export function Classes(props) {
const [courses, setCourses] = useState(courseCache); const [courses, setCourses] = useState(courseCache);
const [sortByCourse, setSortByCourse] = useState(sortCache); const [sortByCourse, setSortByCourse] = useState(sortCache);
const [tagFilter, setTagFilter] = useState(tagFilterCache); const [tagFilter, setTagFilter] = useState(tagFilterCache);
const { token, user } = props; const { token, user, refreshUser } = props;
useEffect(() => { useEffect(() => {
requester('/courses/', 'GET', token) requester('/courses/', 'GET', token)
@ -357,6 +396,9 @@ export function Classes(props) {
<NewClassTable <NewClassTable
classes={classes.filter(classesByTag)} classes={classes.filter(classesByTag)}
courses={courses.filter(coursesByTag)} courses={courses.filter(coursesByTag)}
token={token}
user={user}
refreshUser={refreshUser}
/> />
: :
<ClassTable classes={classes.slice().filter(classesByTag).sort(byDate)} /> <ClassTable classes={classes.slice().filter(classesByTag).sort(byDate)} />

View File

@ -132,12 +132,24 @@ body {
.coursetags .ui.tag.label { .coursetags .ui.tag.label {
margin-top: 1rem; margin-top: 1rem;
} }
.newclasstable { .newclasstable {
margin: 0 -1.5rem 0 -0.5rem; margin: 0 -1.5rem 0 -0.5rem;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
} }
.newclasstable .byline {
display: flex;
justify-content: flex-start;
align-items: center;
}
.newclasstable .byline .interest {
display: inline;
margin-left: auto;
}
.ui.tag.label { .ui.tag.label {
padding-left: 1rem; padding-left: 1rem;
padding-right: 0.5rem; padding-right: 0.5rem;