Add UI and API for expressing interest in a course
This commit is contained in:
parent
12e0e7441b
commit
29980025fb
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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 ?
|
||||||
|
|
|
@ -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 +
|
||||||
|
</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)} />
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in New Issue
Block a user