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()
|
||||
|
||||
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):
|
||||
backup_id = models.TextField()
|
||||
|
||||
|
|
|
@ -521,10 +521,18 @@ class UserTrainingSerializer(serializers.ModelSerializer):
|
|||
exclude = ['user']
|
||||
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):
|
||||
training = UserTrainingSerializer(many=True)
|
||||
member = MemberSerializer()
|
||||
transactions = serializers.SerializerMethodField()
|
||||
interests = serializers.SerializerMethodField()
|
||||
door_code = serializers.SerializerMethodField()
|
||||
wifi_pass = serializers.SerializerMethodField()
|
||||
app_version = serializers.SerializerMethodField()
|
||||
|
@ -543,6 +551,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||
'wifi_pass',
|
||||
'app_version',
|
||||
#'usages',
|
||||
'interests',
|
||||
]
|
||||
depth = 1
|
||||
|
||||
|
@ -554,6 +563,13 @@ class UserSerializer(serializers.ModelSerializer):
|
|||
serializer.is_valid()
|
||||
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):
|
||||
if not obj.member.paused_date and obj.cards.count():
|
||||
return secrets.DOOR_CODE
|
||||
|
|
|
@ -992,6 +992,24 @@ class UsageViewSet(Base):
|
|||
|
||||
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):
|
||||
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'sessions', views.SessionViewSet, basename='session')
|
||||
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'charts/membercount', views.MemberCountViewSet, basename='membercount')
|
||||
router.register(r'charts/signupcount', views.SignupCountViewSet, basename='signupcount')
|
||||
|
|
|
@ -260,7 +260,7 @@ function App() {
|
|||
</Route>
|
||||
|
||||
<Route exact path='/classes'>
|
||||
<Classes token={token} user={user} />
|
||||
<Classes token={token} user={user} refreshUser={refreshUser} />
|
||||
</Route>
|
||||
|
||||
{user && user.member.set_details ?
|
||||
|
|
|
@ -98,49 +98,65 @@ function ClassTable(props) {
|
|||
);
|
||||
};
|
||||
|
||||
function NewClassTable(props) {
|
||||
const { classes, courses } = props;
|
||||
function NewClassTableCourse(props) {
|
||||
const {course, classes, token, user, refreshUser} = props;
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
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],
|
||||
const handleInterest = () => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
const data = { course: course.id };
|
||||
requester('/interest/', 'POST', token, data)
|
||||
.then(res => {
|
||||
setError(false);
|
||||
refreshUser();
|
||||
})
|
||||
.catch(err => {
|
||||
console.log(err);
|
||||
setError(true);
|
||||
});
|
||||
seenCourseIds.push(
|
||||
course_data.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='newclasstable'>
|
||||
{sortedClasses.map(x =>
|
||||
<Segment style={{ margin: '1rem 1rem 0 0', width: '22rem' }}>
|
||||
<Header size='small'>
|
||||
<Link to={'/courses/'+x.course.id}>
|
||||
{x.course.name}
|
||||
<Link to={'/courses/'+course.id}>
|
||||
{course.name}
|
||||
</Link>
|
||||
</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'>
|
||||
{name}
|
||||
</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.Header>
|
||||
<Table.Row>
|
||||
|
@ -151,7 +167,7 @@ function NewClassTable(props) {
|
|||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{x.classes.map(x =>
|
||||
{classes.map(x =>
|
||||
<Table.Row key={x.id} active={x.datetime < now || x.is_cancelled}>
|
||||
<Table.Cell>
|
||||
<Link to={'/classes/'+x.id}>
|
||||
|
@ -176,26 +192,49 @@ function NewClassTable(props) {
|
|||
)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
:
|
||||
<>
|
||||
<p/>
|
||||
<p>No upcoming classes.</p>
|
||||
</>
|
||||
}
|
||||
</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 =>
|
||||
<Segment style={{ margin: '1rem 1rem 0 0', width: '22rem' }}>
|
||||
<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>
|
||||
<NewClassTableCourse course={x} classes={false} token={token} user={user} refreshUser={refreshUser} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
@ -248,7 +287,7 @@ export function Classes(props) {
|
|||
const [courses, setCourses] = useState(courseCache);
|
||||
const [sortByCourse, setSortByCourse] = useState(sortCache);
|
||||
const [tagFilter, setTagFilter] = useState(tagFilterCache);
|
||||
const { token, user } = props;
|
||||
const { token, user, refreshUser } = props;
|
||||
|
||||
useEffect(() => {
|
||||
requester('/courses/', 'GET', token)
|
||||
|
@ -357,6 +396,9 @@ export function Classes(props) {
|
|||
<NewClassTable
|
||||
classes={classes.filter(classesByTag)}
|
||||
courses={courses.filter(coursesByTag)}
|
||||
token={token}
|
||||
user={user}
|
||||
refreshUser={refreshUser}
|
||||
/>
|
||||
:
|
||||
<ClassTable classes={classes.slice().filter(classesByTag).sort(byDate)} />
|
||||
|
|
|
@ -132,12 +132,24 @@ body {
|
|||
.coursetags .ui.tag.label {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.newclasstable {
|
||||
margin: 0 -1.5rem 0 -0.5rem;
|
||||
display: flex;
|
||||
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 {
|
||||
padding-left: 1rem;
|
||||
padding-right: 0.5rem;
|
||||
|
|
Loading…
Reference in New Issue
Block a user