From 29980025fb211fe2d3651505754d15c9591b3b5e Mon Sep 17 00:00:00 2001 From: Tanner Collin Date: Wed, 4 May 2022 01:27:50 +0000 Subject: [PATCH] Add UI and API for expressing interest in a course --- apiserver/apiserver/api/models.py | 7 + apiserver/apiserver/api/serializers.py | 16 +++ apiserver/apiserver/api/views.py | 18 +++ apiserver/apiserver/urls.py | 1 + webclient/src/App.js | 2 +- webclient/src/Classes.js | 180 +++++++++++++++---------- webclient/src/light.css | 12 ++ 7 files changed, 166 insertions(+), 70 deletions(-) diff --git a/apiserver/apiserver/api/models.py b/apiserver/apiserver/api/models.py index ea42a46..7b3dda6 100644 --- a/apiserver/apiserver/api/models.py +++ b/apiserver/apiserver/api/models.py @@ -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() diff --git a/apiserver/apiserver/api/serializers.py b/apiserver/apiserver/api/serializers.py index 861f95f..ec3efa0 100644 --- a/apiserver/apiserver/api/serializers.py +++ b/apiserver/apiserver/api/serializers.py @@ -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 diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py index 6d0abf8..a0dcada 100644 --- a/apiserver/apiserver/api/views.py +++ b/apiserver/apiserver/api/views.py @@ -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 diff --git a/apiserver/apiserver/urls.py b/apiserver/apiserver/urls.py index 65c98b7..16119b8 100644 --- a/apiserver/apiserver/urls.py +++ b/apiserver/apiserver/urls.py @@ -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') diff --git a/webclient/src/App.js b/webclient/src/App.js index f972f2f..7a15ad5 100644 --- a/webclient/src/App.js +++ b/webclient/src/App.js @@ -260,7 +260,7 @@ function App() { - + {user && user.member.set_details ? diff --git a/webclient/src/Classes.js b/webclient/src/Classes.js index 37c6092..945828f 100644 --- a/webclient/src/Classes.js +++ b/webclient/src/Classes.js @@ -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 ( + +
+ + {course.name} + +
+ +
+
+ {!!course.tags && course.tags.split(',').map(name => + + )} +
+ + {user && +
+ {user.interests.includes(course.id) ? + 'Interested ✅' + : + + } +
+ } +
+ + {error &&

Error.

} + + {classes ? + + + + Date + Cost + Students + + + + + {classes.map(x => + + + + {moment.utc(x.datetime).tz('America/Edmonton').format(' MMM Do')} + + {' - '}{x.is_cancelled ? 'Cancelled' : moment.utc(x.datetime).tz('America/Edmonton').format('LT')} + + + {x.cost === '0.00' ? 'Free' : '$'+x.cost.slice(0,2)} + + + {!!x.max_students ? + x.max_students <= x.student_count ? + 'Full' + : + x.student_count + ' / ' + x.max_students + : + x.student_count + } + + + )} + +
+ : + <> +

+

No upcoming classes.

+ + } +
+ ); +} + 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 ( <>
{sortedClasses.map(x => - -
- - {x.course.name} - -
- - {!!x.course.tags && x.course.tags.split(',').map(name => - - )} - - - - - Date - Cost - Students - - - - - {x.classes.map(x => - - - - {moment.utc(x.datetime).tz('America/Edmonton').format(' MMM Do')} - - {' - '}{x.is_cancelled ? 'Cancelled' : moment.utc(x.datetime).tz('America/Edmonton').format('LT')} - - - {x.cost === '0.00' ? 'Free' : '$'+x.cost.slice(0,2)} - - - {!!x.max_students ? - x.max_students <= x.student_count ? - 'Full' - : - x.student_count + ' / ' + x.max_students - : - x.student_count - } - - - )} - -
-
+ )} {courses.filter(x => !seenCourseIds.includes(x.id)).map(x => - -
- - {x.name} - -
- - {!!x.tags && x.tags.split(',').map(name => - - )} - -

-

No upcoming classes.

-
+ )}
@@ -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) { : diff --git a/webclient/src/light.css b/webclient/src/light.css index cf4ca6d..bf5108c 100644 --- a/webclient/src/light.css +++ b/webclient/src/light.css @@ -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;