Add option to unlock and relock perks in the tree

This commit is contained in:
Pavle Portic 2019-03-26 03:51:12 +01:00
parent a38afc2d6a
commit 9f2b21574b
Signed by: TheEdgeOfRage
GPG Key ID: 6758ACE46AA2A849
19 changed files with 327 additions and 150 deletions

View File

@ -4,6 +4,7 @@ from perks import views
urlpatterns = [ urlpatterns = [
path('trees', views.TreeView.as_view()), path('trees', views.TreeView.as_view()),
path('trees/<int:tree_id>', views.PerkView.as_view()), path('trees/<int:tree_id>', views.PerkView.as_view()),
path('users/<int:user_id>', views.UserView.as_view()), path('user', views.UserView.as_view()),
# path('unlock/<int:perk_id>', views.UserView.as_view()),
] ]

View File

@ -9,7 +9,7 @@
from os import environ from os import environ
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import authentication # , permissions from rest_framework import permissions
from .parser import PerkParser from .parser import PerkParser
from .models import Perk, Tree, User from .models import Perk, Tree, User
@ -23,7 +23,6 @@ if not PERKS_DIR:
class TreeView(APIView): class TreeView(APIView):
authentication_classes = (authentication.TokenAuthentication,)
# permission_classes = (permissions.IsAuthenticated,) # permission_classes = (permissions.IsAuthenticated,)
def get(self, request, format=None): def get(self, request, format=None):
@ -47,7 +46,6 @@ class TreeView(APIView):
class PerkView(APIView): class PerkView(APIView):
authentication_classes = (authentication.TokenAuthentication,)
# permission_classes = (permissions.IsAuthenticated,) # permission_classes = (permissions.IsAuthenticated,)
def get(self, request, tree_id, format=None): def get(self, request, tree_id, format=None):
@ -85,11 +83,29 @@ class PerkView(APIView):
class UserView(APIView): class UserView(APIView):
authentication_classes = (authentication.TokenAuthentication,) permission_classes = (permissions.IsAuthenticated,)
def get(self, request, user_id): def get(self, request):
user = User.objects.get(id=user_id) user = User.objects.get(base_user__id=request.user.id)
serialized_user = UserSerializer(user).data serialized_user = UserSerializer(user).data
return Response(serialized_user) return Response(serialized_user)
def patch(self, request):
user = User.objects.get(base_user__id=request.user.id)
if 'perks' in request.data:
current_perks = [perk.id for perk in user.perks.all()]
new_perks = request.data['perks']
if len(current_perks) < len(new_perks):
for perk_id in new_perks:
perk = Perk.objects.get(id=perk_id)
user.perks.add(perk)
elif len(current_perks) > len(new_perks):
removed_perks = list(set(current_perks) ^ set(new_perks))
for perk_id in removed_perks:
perk = Perk.objects.get(id=perk_id)
user.perks.remove(perk)
serialized_user = UserSerializer(user).data
return Response(serialized_user)

View File

@ -129,8 +129,6 @@ USE_TZ = True
STATIC_URL = '/django-static/' STATIC_URL = '/django-static/'
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 10,
'DEFAULT_AUTHENTICATION_CLASSES': ( 'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication', 'rest_framework_simplejwt.authentication.JWTAuthentication',
) )

View File

@ -25,9 +25,9 @@ from rest_framework_simplejwt.views import (
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('api/', include([ path('api/', include([
path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('token', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('token/refresh', TokenRefreshView.as_view(), name='token_refresh'),
path('token/verify/', TokenVerifyView.as_view(), name='token_verify'), path('token/verify', TokenVerifyView.as_view(), name='token_verify'),
path('', include('perks.urls')) path('', include('perks.urls'))
])) ]))
] ]

View File

@ -8,10 +8,9 @@
import Axios from 'axios'; import Axios from 'axios';
const ENDPOINTS = { const ENDPOINTS = {
LOGIN: '/token/', LOGIN: '/token',
VERIFY: '/token/verify/', VERIFY: '/token/verify',
REFRESH: '/token/refresh/', REFRESH: '/token/refresh',
USER: '/user/',
}; };
const AUTH_HEADER = 'Authorization'; const AUTH_HEADER = 'Authorization';
@ -24,10 +23,6 @@ export default class AuthApi {
return Axios.post(ENDPOINTS.LOGIN, data); return Axios.post(ENDPOINTS.LOGIN, data);
} }
static getUser() {
return Axios.get(ENDPOINTS.USER);
}
static signup(data) { static signup(data) {
return Axios.post(ENDPOINTS.USER, data); return Axios.post(ENDPOINTS.USER, data);
} }

View File

@ -9,6 +9,7 @@ import Axios from 'axios';
const ENDPOINTS = { const ENDPOINTS = {
TREES: '/trees', TREES: '/trees',
PERKS: '/perks',
}; };
export default class AuthApi { export default class AuthApi {

View File

@ -0,0 +1,23 @@
/*
* user.api.js
* Copyright (C) 2019 pavle <pavle.portic@tilda.center>
*
* Distributed under terms of the BSD-3-Clause license.
*/
import Axios from 'axios';
const ENDPOINTS = {
USER: '/user',
};
export default class AuthApi {
static getUser() {
return Axios.get(ENDPOINTS.USER);
}
static updatePerks(data) {
return Axios.patch(ENDPOINTS.USER, data);
}
}

View File

@ -14,7 +14,7 @@
></v-text-field> ></v-text-field>
<v-btn <v-btn
flat flat
v-for="item in toolbarItems" v-for="item in toolbarItems[authStatus]"
:key="item.text" :key="item.text"
:to="item.to" :to="item.to"
> >
@ -38,28 +38,25 @@ export default {
name: 'app', name: 'app',
data() { data() {
return { return {
// toolbarItems: { toolbarItems: {
// 'loggedIn': [ true: [
// { icon: 'fas fa-code-branch ', text: 'Perk trees', path: '/trees' }, { icon: 'fas fa-upload', text: 'Upload perks', to: { name: 'upload-perks' } },
// { icon: 'account_circle', text: 'Admin panel', path: '/admin' }, { icon: 'fas fa-code-branch', text: 'Perk trees', to: { name: 'trees' } },
// { icon: 'exit_to_app', text: 'Logout', path: '/logout' }, { icon: 'fas fa-sign-out-alt', text: 'Logout', to: { name: 'logout' } },
// ], ],
// 'loggedOut': [{ icon: 'lock_open', text: 'Login', path: '/login' }], false: [{ icon: 'fas fa-sign-in-alt', text: 'Login', to: { name: 'login' } }],
// }, },
toolbarItems: [
{ icon: 'fas fa-upload', text: 'Upload perks', to: { name: 'upload-perks' } },
{ icon: 'fas fa-code-branch', text: 'Perk trees', to: { name: 'trees' } },
],
}; };
}, },
computed: { computed: {
...mapGetters(['token']), ...mapGetters(['authStatus']),
authStatus() {
return this.token ? 'loggedIn' : 'loggedOut';
},
}, },
mounted() { mounted() {
AuthController.refreshToken(); AuthController.refreshToken().catch(() => {
if (!this.$route.meta.guest) {
this.$router.push({ name: 'index' });
}
});
}, },
}; };
</script> </script>

View File

@ -1,90 +1,90 @@
<template> <template>
<v-layout> <v-layout>
<v-flex md12 lg6 offset-lg3 > <v-flex md12 lg6 offset-lg3 >
<v-card class="signup-form-card"> <v-card class="signup-form-card">
<h3>Sign up</h3> <h3>Sign up</h3>
<form @submit="submit"> <form @submit="submit">
<v-text-field <v-text-field
v-model="username" v-model="username"
name="username" label="Username"
label="Username" type="text"
type="text" required>
v-validate="'required|max:255'" </v-text-field>
data-vv-name="username" <v-text-field
required> v-model="email"
</v-text-field> label="Email"
<v-text-field type="email"
v-model="password" required>
name="password" </v-text-field>
label="Password" <v-text-field
type="password" v-model="password"
v-validate="'required|min:8|max:20'" label="Password"
data-vv-name="password" type="password"
required> required>
</v-text-field> </v-text-field>
<v-text-field <v-text-field
v-model="passwordConfirm" v-model="passwordConfirm"
name="passwordConfirm" label="Confirm password"
label="Confirm password" type="password"
type="password" required>
v-validate="'required|min:8|max:20'" </v-text-field>
data-vv-name="passwordConfirm" <p v-for="(error, index) in signupErrors"
required> :key="index"
</v-text-field> class="signup-errors">
<p v-for="(error, index) in signupErrors" {{ error }}
:key="index" </p>
class="signup-errors">
{{ error }}
</p>
<v-btn type="submit">Signup</v-btn> <v-btn type="submit">Signup</v-btn>
</form> </form>
</v-card> </v-card>
</v-flex> </v-flex>
</v-layout> </v-layout>
</template> </template>
<script> <script>
import AuthController from '../../controllers/auth.controller'; import AuthController from '../../controllers/auth.controller';
export default { export default {
name: 'signup', name: 'signup',
data() { data() {
return { return {
username: '', username: '',
password: '', email: '',
passwordConfirm: '', password: '',
signupErrors: [], passwordConfirm: '',
}; signupErrors: [],
}, };
methods: { },
submit(event) { methods: {
event.preventDefault(); submit(event) {
if (this.password !== this.passwordConfirm) { event.preventDefault();
this.signupErrors.push('Лозинке нису исте'); if (this.password !== this.passwordConfirm) {
return; this.signupErrors.push('Passwords do not match');
} return;
}
const data = { const data = {
username: this.username, username: this.username,
password: this.password, email: this.email,
}; password: this.password,
AuthController.register(data).then(() => { };
this.$router.push({ name: 'index' }); AuthController.register(data).then(() => {
}).catch((error) => { this.$router.push({ name: 'index' });
if (error.response.status === 401) { }).catch((error) => {
this.signupErrors.push(error.response.data); if (error.response) {
} this.signupErrors.push(error.response.data);
}); }
}, });
}, },
}; },
};
</script> </script>
<style lang="stylus"> <style lang="stylus">
.signup-form-card .signup-form-card
padding 2rem padding 2rem
.login-errors .login-errors
color red color red
</style> </style>

View File

@ -15,16 +15,32 @@
<v-card-title <v-card-title
class="headline grey darken-2" class="headline grey darken-2"
primary-title primary-title
>{{ perk }}</v-card-title> >
<v-card-text>{{ effect }}</v-card-text> {{ selectedPerk.name }}
<v-spacer></v-spacer>
{{ selectedPerk.level }}
</v-card-title>
<v-card-text>{{ selectedPerk.effect }}</v-card-text>
<v-divider></v-divider> <v-divider></v-divider>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn <v-btn
color="primary" color="error"
flat flat
@click="dialog = false" @click="dialog = false"
>Close</v-btn> >Close</v-btn>
<v-btn
v-if="canUnlock"
color="primary"
flat
@click="unlock()"
>Unlock</v-btn>
<v-btn
v-else-if="canLock"
color="warning"
flat
@click="lock()"
>Lock</v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
@ -36,7 +52,8 @@ import * as d3 from 'd3';
import Sankey from 'd3.chart.sankey'; import Sankey from 'd3.chart.sankey';
import * as _ from 'lodash'; import * as _ from 'lodash';
import PerksController from '../controllers/perks.controller'; import PerkController from '../controllers/perk.controller';
import UserController from '../controllers/user.controller';
export default { export default {
name: 'perks', name: 'perks',
@ -45,8 +62,20 @@ export default {
data () { data () {
return { return {
dialog: false, dialog: false,
perk: '', selectedPerk: {
effect: '', id: null,
name: '',
effect: '',
level: null,
},
user: {
perks: [],
base_user: {},
},
graphData: {
nodes: [],
links: [],
},
colorScheme: [ colorScheme: [
'#458588', '#458588',
'#d79921', '#d79921',
@ -56,6 +85,30 @@ export default {
], ],
}; };
}, },
computed: {
canUnlock() {
const isLocked = _.indexOf(this.user.perks, this.selectedPerk.id);
if (isLocked !== -1) {
return false;
}
const requirements = _.filter(this.graphData.links, { target: this.selectedPerk });
const requirement_ids = _.map(requirements, 'source.id');
return requirement_ids.every((req) => this.user.perks.includes(req));
},
canLock() {
const dependencies = _.filter(this.graphData.links, { source: this.selectedPerk });
const dependency_ids = _.map(dependencies, 'target.id');
const isLocked = _.indexOf(this.user.perks, this.selectedPerk.id);
let hasUnlockedDependencies = false;
if (dependencies.length !== 0) {
hasUnlockedDependencies = dependency_ids.every((req) => this.user.perks.includes(req));
}
return !hasUnlockedDependencies && isLocked !== -1;
},
},
methods: { methods: {
renderGraph(graphData) { renderGraph(graphData) {
const svg = d3.select('#perktree').append('svg'); const svg = d3.select('#perktree').append('svg');
@ -73,21 +126,58 @@ export default {
return this.colorScheme[node.type]; return this.colorScheme[node.type];
}) })
.on('node:click', (node) => { .on('node:click', (node) => {
const clicked_node = _.find(nodes, (n) => { const clickedNode = _.find(nodes, (n) => {
return n.name === node.name; return n.name === node.name;
}); });
if (clicked_node.effect) { if (clickedNode.effect) {
this.perk = clicked_node.name; this.selectedPerk = clickedNode;
this.effect = clicked_node.effect;
this.dialog = true; this.dialog = true;
} }
}) })
.draw(graphData); .draw(graphData);
}, },
unlock() {
if (this.canUnlock) {
const newPerks = _.clone(this.user.perks);
newPerks.push(this.selectedPerk.id);
UserController.updatePerks(newPerks).then((response) => {
this.user = response.data;
this.markUnlockedPerks();
this.dialog = false;
});
}
},
lock() {
if (this.canLock) {
const newPerks = _.difference(this.user.perks, [this.selectedPerk.id]);
UserController.updatePerks(newPerks).then((response) => {
this.user = response.data;
this.markUnlockedPerks();
this.dialog = false;
});
}
},
markUnlockedPerks() {
for (let i = 0; i < this.graphData.nodes.length; i++) {
const perkId = this.graphData.nodes[i].id;
const el = this.$el.querySelector(`[data-node-id="${perkId}"] rect`);
if (_.indexOf(this.user.perks, perkId) !== -1) {
el.setAttribute('style', `stroke: #d65d0e !important; stroke-width: 3px; fill: ${el.style.fill};`);
} else {
el.setAttribute('style', `fill: ${el.style.fill};`);
}
}
},
}, },
mounted() { mounted() {
PerksController.getPerks(this.$route.params.tree).then((response) => { PerkController.getPerks(this.$route.params.tree).then((response) => {
this.renderGraph(response.data); this.graphData = response.data;
this.renderGraph(this.graphData);
UserController.getUser().then((response) => {
this.user = response.data;
this.markUnlockedPerks();
});
}); });
}, },
}; };

View File

@ -20,7 +20,7 @@
<script> <script>
// import * as _ from 'lodash'; // import * as _ from 'lodash';
import PerksController from '../controllers/perks.controller'; import PerkController from '../controllers/perk.controller';
export default { export default {
name: 'trees', name: 'trees',
@ -37,7 +37,7 @@ export default {
}, },
}, },
mounted() { mounted() {
PerksController.getTrees().then((response) => { PerkController.getTrees().then((response) => {
this.trees = response.data; this.trees = response.data;
}); });
}, },

View File

@ -49,7 +49,7 @@
<script> <script>
import UploadButton from 'vuetify-upload-button'; import UploadButton from 'vuetify-upload-button';
import PerksController from '../controllers/perks.controller'; import PerkController from '../controllers/perk.controller';
export default { export default {
name: 'admin-panel', name: 'admin-panel',
@ -71,7 +71,7 @@ export default {
methods: { methods: {
upload(file) { upload(file) {
this.loading = true; this.loading = true;
PerksController.uploadPerks(file).then(() => { PerkController.uploadPerks(file).then(() => {
this.loading = false; this.loading = false;
this.$router.push({ name: 'trees' }); this.$router.push({ name: 'trees' });
}).catch((error) => { }).catch((error) => {

View File

@ -0,0 +1,28 @@
<template>
<v-container>
<h1>Dashboard</h1>
</v-container>
</template>
<script>
import AuthController from '../controllers/auth.controller';
export default {
name: 'Index',
components: {
},
data () {
return {
};
},
methods: {
},
mounted() {
AuthController.getUser().then((response) => {
console.log(response.data);
});
},
};
</script>

View File

@ -1,5 +1,5 @@
/* /*
* perks.controller.js * auth.controller.js
* Copyright (C) 2019 pavle <pavle.portic@tilda.center> * Copyright (C) 2019 pavle <pavle.portic@tilda.center>
* *
* Distributed under terms of the BSD-3-Clause license. * Distributed under terms of the BSD-3-Clause license.
@ -40,7 +40,7 @@ export default class AuthController {
static setupToken() { static setupToken() {
const access = this.getLocalStorageToken().access; const access = this.getLocalStorageToken().access;
AuthApi.setAuthHeader(access); AuthApi.setAuthHeader(access);
store.commit('setToken', access); store.commit('login');
} }
static login(data) { static login(data) {
@ -56,7 +56,7 @@ export default class AuthController {
static logout() { static logout() {
this.clearLocalStorageToken(); this.clearLocalStorageToken();
store.commit('clearToken'); store.commit('logout');
AuthApi.setAuthHeader(''); AuthApi.setAuthHeader('');
} }

View File

@ -1,14 +1,13 @@
/* /*
* perks.controller.js * perk.controller.js
* Copyright (C) 2019 pavle <pavle.portic@tilda.center> * Copyright (C) 2019 pavle <pavle.portic@tilda.center>
* *
* Distributed under terms of the BSD-3-Clause license. * Distributed under terms of the BSD-3-Clause license.
*/ */
import PerkApi from '../apis/perk.api'; import PerkApi from '../apis/perk.api';
// import router from '../router';
export default class PerksController { export default class PerkController {
static getTrees() { static getTrees() {
return PerkApi.getTrees(); return PerkApi.getTrees();
} }
@ -23,3 +22,4 @@ export default class PerksController {
return PerkApi.uploadPerks(data); return PerkApi.uploadPerks(data);
} }
} }

View File

@ -0,0 +1,23 @@
/*
* user.controller.js
* Copyright (C) 2019 pavle <pavle.portic@tilda.center>
*
* Distributed under terms of the BSD-3-Clause license.
*/
import UserApi from '../apis/user.api';
export default class AuthController {
static getUser() {
return UserApi.getUser();
}
static updatePerks(perks) {
const data = {
perks,
};
return UserApi.updatePerks(data);
}
}

View File

@ -23,14 +23,14 @@ const configureHttp = () => {
}, },
(error) => { (error) => {
if (error.response && error.response.status === 401) { if (error.response && error.response.status === 401) {
// AuthController.refreshToken().catch(() => { AuthController.refreshToken().catch(() => {
// router.push({ router.push({
// name: 'logout', name: 'logout',
// }); });
// });
router.push({
name: 'logout',
}); });
// router.push({
// name: 'logout',
// });
} }
return Promise.reject(error); return Promise.reject(error);

View File

@ -68,6 +68,11 @@ const router = new Router({
guest: true, guest: true,
}, },
}, },
{
path: '/user',
name: 'user',
component: () => import(/* webpackChunkName: "user" */ './components/user.component'),
},
], ],
}); });
@ -78,7 +83,7 @@ router.isCurrentRoute = (routeName) => {
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
if (to.name === 'logout') { if (to.name === 'logout') {
AuthController.logout(); AuthController.logout();
return next({ name: 'login' }); return next({ name: 'index' });
} }
if (!to.meta.guest && !AuthController.getAuthStatus()) { if (!to.meta.guest && !AuthController.getAuthStatus()) {

View File

@ -11,19 +11,19 @@ import Vuex from 'vuex';
Vue.use(Vuex); Vue.use(Vuex);
const state = { const state = {
token: null, authStatus: null,
}; };
const getters = { const getters = {
token: (state) => state.token, authStatus: (state) => state.authStatus,
}; };
const mutations = { const mutations = {
setToken(token) { login() {
state.token = token; state.authStatus = true;
}, },
clearToken() { logout() {
state.token = ''; state.authStatus = false;
}, },
}; };