From a0f9007d373dedb1df268a20e8cf9472971f8767 Mon Sep 17 00:00:00 2001 From: Adrian Dmitra Date: Tue, 29 Aug 2023 17:26:37 -0600 Subject: [PATCH] partial #115 enable members to vouch for each other enable admin to view vouching info on all users --- apiserver/apiserver/api/models.py | 3 ++- apiserver/apiserver/api/serializers.py | 2 ++ apiserver/apiserver/api/views.py | 20 ++++++++++++++- apiserver/apiserver/urls.py | 3 ++- webclient/src/Account.js | 18 +++++++++++++- webclient/src/AdminMembers.js | 17 +++++++++++-- webclient/src/App.js | 4 +-- webclient/src/Members.js | 31 ++++++++++++++++++++---- webclient/src/components/MembersList.jsx | 19 +++++++++++++++ 9 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 webclient/src/components/MembersList.jsx diff --git a/apiserver/apiserver/api/models.py b/apiserver/apiserver/api/models.py index 4a66f40..5a3b600 100644 --- a/apiserver/apiserver/api/models.py +++ b/apiserver/apiserver/api/models.py @@ -19,6 +19,7 @@ def today_alberta_tz(): class Member(models.Model): user = models.OneToOneField(User, related_name='member', blank=True, null=True, on_delete=models.SET_NULL) signup_helper = models.ForeignKey(User, related_name='signed_up', blank=True, null=True, on_delete=models.SET_NULL) + sponsorship = models.ManyToManyField('self', related_name='sponsored_by', symmetrical=False, blank=True) old_email = models.CharField(max_length=254, blank=True, null=True) photo_large = models.CharField(max_length=64, blank=True, null=True) photo_medium = models.CharField(max_length=64, blank=True, null=True) @@ -331,4 +332,4 @@ class HistoryChange(models.Model): list_display = ['field', 'old', 'new', 'index'] search_fields = ['field', 'old', 'new', 'index__history_user__username'] def __str__(self): - return self.field + return self.field \ No newline at end of file diff --git a/apiserver/apiserver/api/serializers.py b/apiserver/apiserver/api/serializers.py index 3b5c960..c264306 100644 --- a/apiserver/apiserver/api/serializers.py +++ b/apiserver/apiserver/api/serializers.py @@ -248,6 +248,8 @@ class MemberSerializer(serializers.ModelSerializer): email = fields.UserEmailField(serializers.EmailField) phone = serializers.CharField() protocoin = serializers.SerializerMethodField() + sponsorship = OtherMemberSerializer(many=True, read_only=True) + sponsored_by = OtherMemberSerializer(many=True, read_only=True) total_protocoin = serializers.SerializerMethodField() class Meta: diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py index 74f78e9..91ceaae 100644 --- a/apiserver/apiserver/api/views.py +++ b/apiserver/apiserver/api/views.py @@ -1895,6 +1895,24 @@ class StorageSpaceViewSet(Base, List, Retrieve, Update): return Response(200) +class SponsorshipViewSet(Base): + permission_classes = [AllowMetadata | IsAuthenticated] + queryset = models.Member.objects.all() + + @action(detail=True, methods=['post']) + def offer(self, request, pk=None): + value = self.request.data['value'] + member = self.get_object() + sponsor = self.request.user.member + if value == 'true': + sponsor.sponsorship.add(member.id) + logging.info('Member %s is now sponsoring member %s', sponsor.preferred_name, member.preferred_name) + else: + sponsor.sponsorship.remove(member.id) + logging.info('Member %s revoked sponsorship for member %s', sponsor.preferred_name, member.preferred_name) + sponsor.save() + return Response(200) + class RegistrationView(RegisterView): serializer_class = serializers.MyRegisterSerializer @@ -1917,4 +1935,4 @@ class MyLoginView(LoginView): @api_view() def null_view(request, *args, **kwargs): - raise Http404 + raise Http404 \ No newline at end of file diff --git a/apiserver/apiserver/urls.py b/apiserver/apiserver/urls.py index 27d1025..470e1a6 100644 --- a/apiserver/apiserver/urls.py +++ b/apiserver/apiserver/urls.py @@ -19,6 +19,7 @@ router.register(r'members', views.MemberViewSet, basename='members') router.register(r'courses', views.CourseViewSet, basename='course') router.register(r'history', views.HistoryViewSet, basename='history') router.register(r'vetting', views.VettingViewSet, basename='vetting') +router.register(r'sponsorship', views.SponsorshipViewSet, basename='sponsorship') router.register(r'pinball', views.PinballViewSet, basename='pinball') router.register(r'storage', views.StorageSpaceViewSet, basename='storage') router.register(r'hosting', views.HostingViewSet, basename='hosting') @@ -62,4 +63,4 @@ urlpatterns.append(path(ADMIN_ROUTE, admin.site.urls)) if settings.DEBUG: urlpatterns += [ path('api-auth/', include('rest_framework.urls')), - ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/webclient/src/Account.js b/webclient/src/Account.js index 2854e2c..6250829 100644 --- a/webclient/src/Account.js +++ b/webclient/src/Account.js @@ -6,7 +6,9 @@ import 'react-image-crop/dist/ReactCrop.css'; import './light.css'; import { MembersDropdown } from './Members.js'; import { Button, Container, Form, Grid, Header, Message, Segment } from 'semantic-ui-react'; +import './components/MembersList' import { requester, randomString } from './utils.js'; +import { MembersList } from './components/MembersList'; function LogoutEverywhere(props) { const { token } = props; @@ -335,6 +337,19 @@ export function AccountForm(props) { ); }; +export function Sponsorship(props) { + const { user: { member } } = props; + + return ( +
+
My sponsors:
+ +
I am sponsoring:
+ +
+ ); +}; + export function BioNotesForm(props) { const { token, user, refreshUser } = props; const member = user.member; @@ -401,6 +416,7 @@ export function Account(props) { + @@ -410,4 +426,4 @@ export function Account(props) { ); -}; +}; \ No newline at end of file diff --git a/webclient/src/AdminMembers.js b/webclient/src/AdminMembers.js index deeded8..cefc4d9 100644 --- a/webclient/src/AdminMembers.js +++ b/webclient/src/AdminMembers.js @@ -1,10 +1,11 @@ import React, { useState, useEffect } from 'react'; import { Link, useParams } from 'react-router-dom'; import './light.css'; -import { Button, Checkbox, Dimmer, Form, Message, Header, Icon, Image, Segment, Table } from 'semantic-ui-react'; +import { Button, Checkbox, Dimmer, Form, Message, Header, Icon, Image, Segment, Table, List, ListItem } from 'semantic-ui-react'; import moment from 'moment-timezone'; import { statusColor, BasicTable, staticUrl, requester } from './utils.js'; import { TrainingList } from './Training.js'; +import { MembersList } from './components/MembersList'; function AdminCardDetail(props) { const { token, result, card } = props; @@ -615,6 +616,18 @@ export function AdminMemberInfo(props) { + + Vouched by: + + + + + + Vouches for: + + + + Public Bio: @@ -788,4 +801,4 @@ export function AdminMemberCertifications(props) { ); -}; +}; \ No newline at end of file diff --git a/webclient/src/App.js b/webclient/src/App.js index 892e72b..d693001 100644 --- a/webclient/src/App.js +++ b/webclient/src/App.js @@ -334,7 +334,7 @@ function App() { - + @@ -374,4 +374,4 @@ function App() { ) }; -export default App; +export default App; \ No newline at end of file diff --git a/webclient/src/Members.js b/webclient/src/Members.js index ca410ee..cf37463 100644 --- a/webclient/src/Members.js +++ b/webclient/src/Members.js @@ -282,11 +282,16 @@ export function Members(props) { let resultCache = {}; export function MemberDetail(props) { - const { id } = useParams(); + const id = parseInt(useParams().id) const [result, setResult] = useState(resultCache[id] || false); const [refreshCount, refreshResult] = useReducer(x => x + 1, 0); const [error, setError] = useState(false); const { token, user } = props; + const member = result.member || false; + const memberFullName = [member.preferred_name, member.last_name].join(' ') + const isSponsoring = user.member.sponsorship?.find(m => m.id === id) + const isMe = user.member.id === id + const photo = member?.photo_large || member?.photo_small || false; useEffect(() => { requester('/search/'+id+'/', 'GET', token) @@ -300,8 +305,23 @@ export function MemberDetail(props) { }); }, [refreshCount]); - const member = result.member || false; - const photo = member?.photo_large || member?.photo_small || false; + + function sponsorMember (value) { + return () => { + requester(`/sponsorship/${id}/offer/`, 'POST', token, { value }) + .then(res => { + const _user = { ...user } + const sponsorship = _user.member.sponsorship + if (value) sponsorship.push({ id }) + else sponsorship.splice(sponsorship.findIndex(m => m.id === id), 1) + props.setUser(_user) + }) + .catch(err => { + console.log(err); + setError(true); + }); + } + } return ( @@ -342,6 +362,8 @@ export function MemberDetail(props) {

{member.public_bio || 'None yet.'}

+ { !isMe && !isSponsoring && } + { !isMe && isSponsoring && } } @@ -385,5 +407,4 @@ export function MemberDetail(props) { }
); -}; - +}; \ No newline at end of file diff --git a/webclient/src/components/MembersList.jsx b/webclient/src/components/MembersList.jsx new file mode 100644 index 0000000..f00a6e8 --- /dev/null +++ b/webclient/src/components/MembersList.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { List, ListItem } from 'semantic-ui-react'; + +export function MembersList (p) { + const list = p.list + + return ( + + { list.map(_member => ( + + + {_member.preferred_name} {_member.last_name} + + + ))} + + ) +} \ No newline at end of file