Add UI and API for expressing interest in a course

master
Tanner Collin 2 years ago
parent 12e0e7441b
commit 29980025fb
  1. 7
      apiserver/apiserver/api/models.py
  2. 16
      apiserver/apiserver/api/serializers.py
  3. 18
      apiserver/apiserver/api/views.py
  4. 1
      apiserver/apiserver/urls.py
  5. 2
      webclient/src/App.js
  6. 180
      webclient/src/Classes.js
  7. 12
      webclient/src/light.css

@ -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,8 +98,112 @@ function ClassTable(props) {
);
};
function NewClassTableCourse(props) {
const {course, classes, token, user, refreshUser} = props;
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
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);
});
};
const now = new Date().toISOString();
return (
<Segment style={{ margin: '1rem 1rem 0 0', width: '22rem' }}>
<Header size='small'>
<Link to={'/courses/'+course.id}>
{course.name}
</Link>
</Header>
<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&nbsp;+
</Button>
}
</div>
}
</div>
{error && <p>Error.</p>}
{classes ?
<Table compact unstackable singleLine basic='very'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Date</Table.HeaderCell>
<Table.HeaderCell>Cost</Table.HeaderCell>
<Table.HeaderCell>Students</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{classes.map(x =>
<Table.Row key={x.id} active={x.datetime < now || x.is_cancelled}>
<Table.Cell>
<Link to={'/classes/'+x.id}>
{moment.utc(x.datetime).tz('America/Edmonton').format(' MMM Do')}
</Link>
{' - '}{x.is_cancelled ? 'Cancelled' : moment.utc(x.datetime).tz('America/Edmonton').format('LT')}
</Table.Cell>
<Table.Cell>{x.cost === '0.00' ? 'Free' : '$'+x.cost.slice(0,2)}</Table.Cell>
<Table.Cell>
{!!x.max_students ?
x.max_students <= x.student_count ?
'Full'
:
x.student_count + ' / ' + x.max_students
:
x.student_count
}
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table>
:
<>
<p/>
<p>No upcoming classes.</p>
</>
}
</Segment>
);
}
function NewClassTable(props) {
const { classes, courses } = props;
const { classes, courses, token, user, refreshUser } = props;
let sortedClasses = [];
let seenCourseIds = [];
@ -122,80 +226,15 @@ function NewClassTable(props) {
}
}
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>
</Header>
{!!x.course.tags && x.course.tags.split(',').map(name =>
<Label color={tags[name]} tag size='small'>
{name}
</Label>
)}
<Table compact unstackable singleLine basic='very'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Date</Table.HeaderCell>
<Table.HeaderCell>Cost</Table.HeaderCell>
<Table.HeaderCell>Students</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{x.classes.map(x =>
<Table.Row key={x.id} active={x.datetime < now || x.is_cancelled}>
<Table.Cell>
<Link to={'/classes/'+x.id}>
{moment.utc(x.datetime).tz('America/Edmonton').format(' MMM Do')}
</Link>
{' - '}{x.is_cancelled ? 'Cancelled' : moment.utc(x.datetime).tz('America/Edmonton').format('LT')}
</Table.Cell>
<Table.Cell>{x.cost === '0.00' ? 'Free' : '$'+x.cost.slice(0,2)}</Table.Cell>
<Table.Cell>
{!!x.max_students ?
x.max_students <= x.student_count ?
'Full'
:
x.student_count + ' / ' + x.max_students
:
x.student_count
}
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table>
</Segment>
<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…
Cancel
Save