enable members to vouch for each other
enable admin to view vouching info on all users
master
Adrian Dmitra 8 months ago
parent 6bab989d42
commit a0f9007d37
  1. 3
      apiserver/apiserver/api/models.py
  2. 2
      apiserver/apiserver/api/serializers.py
  3. 20
      apiserver/apiserver/api/views.py
  4. 3
      apiserver/apiserver/urls.py
  5. 18
      webclient/src/Account.js
  6. 17
      webclient/src/AdminMembers.js
  7. 4
      webclient/src/App.js
  8. 31
      webclient/src/Members.js
  9. 19
      webclient/src/components/MembersList.jsx

@ -19,6 +19,7 @@ def today_alberta_tz():
class Member(models.Model): class Member(models.Model):
user = models.OneToOneField(User, related_name='member', blank=True, null=True, on_delete=models.SET_NULL) 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) 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) old_email = models.CharField(max_length=254, blank=True, null=True)
photo_large = models.CharField(max_length=64, 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) 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'] list_display = ['field', 'old', 'new', 'index']
search_fields = ['field', 'old', 'new', 'index__history_user__username'] search_fields = ['field', 'old', 'new', 'index__history_user__username']
def __str__(self): def __str__(self):
return self.field return self.field

@ -248,6 +248,8 @@ class MemberSerializer(serializers.ModelSerializer):
email = fields.UserEmailField(serializers.EmailField) email = fields.UserEmailField(serializers.EmailField)
phone = serializers.CharField() phone = serializers.CharField()
protocoin = serializers.SerializerMethodField() protocoin = serializers.SerializerMethodField()
sponsorship = OtherMemberSerializer(many=True, read_only=True)
sponsored_by = OtherMemberSerializer(many=True, read_only=True)
total_protocoin = serializers.SerializerMethodField() total_protocoin = serializers.SerializerMethodField()
class Meta: class Meta:

@ -1895,6 +1895,24 @@ class StorageSpaceViewSet(Base, List, Retrieve, Update):
return Response(200) 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): class RegistrationView(RegisterView):
serializer_class = serializers.MyRegisterSerializer serializer_class = serializers.MyRegisterSerializer
@ -1917,4 +1935,4 @@ class MyLoginView(LoginView):
@api_view() @api_view()
def null_view(request, *args, **kwargs): def null_view(request, *args, **kwargs):
raise Http404 raise Http404

@ -19,6 +19,7 @@ router.register(r'members', views.MemberViewSet, basename='members')
router.register(r'courses', views.CourseViewSet, basename='course') router.register(r'courses', views.CourseViewSet, basename='course')
router.register(r'history', views.HistoryViewSet, basename='history') 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'sponsorship', views.SponsorshipViewSet, basename='sponsorship')
router.register(r'pinball', views.PinballViewSet, basename='pinball') router.register(r'pinball', views.PinballViewSet, basename='pinball')
router.register(r'storage', views.StorageSpaceViewSet, basename='storage') router.register(r'storage', views.StorageSpaceViewSet, basename='storage')
router.register(r'hosting', views.HostingViewSet, basename='hosting') router.register(r'hosting', views.HostingViewSet, basename='hosting')
@ -62,4 +63,4 @@ urlpatterns.append(path(ADMIN_ROUTE, admin.site.urls))
if settings.DEBUG: if settings.DEBUG:
urlpatterns += [ urlpatterns += [
path('api-auth/', include('rest_framework.urls')), 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)

@ -6,7 +6,9 @@ import 'react-image-crop/dist/ReactCrop.css';
import './light.css'; import './light.css';
import { MembersDropdown } from './Members.js'; import { MembersDropdown } from './Members.js';
import { Button, Container, Form, Grid, Header, Message, Segment } from 'semantic-ui-react'; import { Button, Container, Form, Grid, Header, Message, Segment } from 'semantic-ui-react';
import './components/MembersList'
import { requester, randomString } from './utils.js'; import { requester, randomString } from './utils.js';
import { MembersList } from './components/MembersList';
function LogoutEverywhere(props) { function LogoutEverywhere(props) {
const { token } = props; const { token } = props;
@ -335,6 +337,19 @@ export function AccountForm(props) {
); );
}; };
export function Sponsorship(props) {
const { user: { member } } = props;
return (
<div>
<Header size='medium'>My sponsors:</Header>
<MembersList list={ member.sponsored_by }/>
<Header size='medium'>I am sponsoring:</Header>
<MembersList list={ member.sponsorship }/>
</div>
);
};
export function BioNotesForm(props) { export function BioNotesForm(props) {
const { token, user, refreshUser } = props; const { token, user, refreshUser } = props;
const member = user.member; const member = user.member;
@ -401,6 +416,7 @@ export function Account(props) {
<Grid stackable columns={2}> <Grid stackable columns={2}>
<Grid.Column> <Grid.Column>
<Segment padded><AccountForm {...props} /></Segment> <Segment padded><AccountForm {...props} /></Segment>
<Segment padded><Sponsorship {...props} /></Segment>
</Grid.Column> </Grid.Column>
<Grid.Column> <Grid.Column>
<Segment padded><BioNotesForm {...props} /></Segment> <Segment padded><BioNotesForm {...props} /></Segment>
@ -410,4 +426,4 @@ export function Account(props) {
</Grid> </Grid>
</Container> </Container>
); );
}; };

@ -1,10 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import './light.css'; 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 moment from 'moment-timezone';
import { statusColor, BasicTable, staticUrl, requester } from './utils.js'; import { statusColor, BasicTable, staticUrl, requester } from './utils.js';
import { TrainingList } from './Training.js'; import { TrainingList } from './Training.js';
import { MembersList } from './components/MembersList';
function AdminCardDetail(props) { function AdminCardDetail(props) {
const { token, result, card } = props; const { token, result, card } = props;
@ -615,6 +616,18 @@ export function AdminMemberInfo(props) {
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
<Table.Row>
<Table.Cell>Vouched by:</Table.Cell>
<Table.Cell>
<MembersList list={ member.sponsored_by }/>
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Vouches for:</Table.Cell>
<Table.Cell>
<MembersList list={ member.sponsorship }/>
</Table.Cell>
</Table.Row>
<Table.Row> <Table.Row>
<Table.Cell>Public Bio:</Table.Cell> <Table.Cell>Public Bio:</Table.Cell>
</Table.Row> </Table.Row>
@ -788,4 +801,4 @@ export function AdminMemberCertifications(props) {
</div> </div>
); );
}; };

@ -334,7 +334,7 @@ function App() {
</Route> </Route>
<Route path='/members/:id'> <Route path='/members/:id'>
<MemberDetail token={token} user={user} /> <MemberDetail token={token} user={user} setUser={setUserCache}/>
</Route> </Route>
<Route path='/members'> <Route path='/members'>
<Members token={token} user={user} /> <Members token={token} user={user} />
@ -374,4 +374,4 @@ function App() {
) )
}; };
export default App; export default App;

@ -282,11 +282,16 @@ export function Members(props) {
let resultCache = {}; let resultCache = {};
export function MemberDetail(props) { export function MemberDetail(props) {
const { id } = useParams(); const id = parseInt(useParams().id)
const [result, setResult] = useState(resultCache[id] || false); const [result, setResult] = useState(resultCache[id] || false);
const [refreshCount, refreshResult] = useReducer(x => x + 1, 0); const [refreshCount, refreshResult] = useReducer(x => x + 1, 0);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const { token, user } = props; 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(() => { useEffect(() => {
requester('/search/'+id+'/', 'GET', token) requester('/search/'+id+'/', 'GET', token)
@ -300,8 +305,23 @@ export function MemberDetail(props) {
}); });
}, [refreshCount]); }, [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 ( return (
<Container> <Container>
@ -342,6 +362,8 @@ export function MemberDetail(props) {
<p className='bio-paragraph'> <p className='bio-paragraph'>
{member.public_bio || 'None yet.'} {member.public_bio || 'None yet.'}
</p> </p>
{ !isMe && !isSponsoring && <Button onClick={ sponsorMember(true) }>Vouch for { member.preferred_name }</Button> }
{ !isMe && isSponsoring && <Button onClick={ sponsorMember(false) }>Revoke guarantee</Button> }
</React.Fragment> </React.Fragment>
} }
</Grid.Column> </Grid.Column>
@ -385,5 +407,4 @@ export function MemberDetail(props) {
} }
</Container> </Container>
); );
}; };

@ -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>
{ list.map(_member => (
<ListItem key={'member' + _member.id}>
<Link to={'/members/' + _member.id + '/'}>
{_member.preferred_name} {_member.last_name}
</Link>
</ListItem>
))}
</List>
)
}
Loading…
Cancel
Save