751 config fields interface (#1607)

* Add API for fields at `/api/v1/configs/fields`
* Add interface to manipulate fields from the Admin Panel
This commit is contained in:
Kevin Chung
2020-08-19 00:17:25 -04:00
committed by GitHub
parent f7de0c71a5
commit b26774810d
8 changed files with 530 additions and 2 deletions

View File

@@ -8,8 +8,9 @@ from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.cache import clear_config, clear_standings from CTFd.cache import clear_config, clear_standings
from CTFd.constants import RawEnum from CTFd.constants import RawEnum
from CTFd.models import Configs, db from CTFd.models import Fields, Configs, db
from CTFd.schemas.config import ConfigSchema from CTFd.schemas.config import ConfigSchema
from CTFd.schemas.fields import FieldSchema
from CTFd.utils import set_config from CTFd.utils import set_config
from CTFd.utils.decorators import admins_only from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters from CTFd.utils.helpers.models import build_model_filters
@@ -189,3 +190,89 @@ class Config(Resource):
clear_standings() clear_standings()
return {"success": True} return {"success": True}
@configs_namespace.route("/fields")
class FieldList(Resource):
@admins_only
@validate_args(
{
"type": (str, None),
"q": (str, None),
"field": (RawEnum("FieldFields", {"description": "description"}), None),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Fields, query=q, field=field)
fields = Fields.query.filter_by(**query_args).filter(*filters).all()
schema = FieldSchema(many=True)
response = schema.dump(fields)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
def post(self):
req = request.get_json()
schema = FieldSchema()
response = schema.load(req, session=db.session)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.add(response.data)
db.session.commit()
response = schema.dump(response.data)
db.session.close()
return {"success": True, "data": response.data}
@configs_namespace.route("/fields/<field_id>")
class Field(Resource):
@admins_only
def get(self, field_id):
field = Fields.query.filter_by(id=field_id).first_or_404()
schema = FieldSchema()
response = schema.dump(field)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
def patch(self, field_id):
field = Fields.query.filter_by(id=field_id).first_or_404()
schema = FieldSchema()
req = request.get_json()
response = schema.load(req, session=db.session, instance=field)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.commit()
response = schema.dump(response.data)
db.session.close()
return {"success": True, "data": response.data}
@admins_only
def delete(self, field_id):
field = Fields.query.filter_by(id=field_id).first_or_404()
db.session.delete(field)
db.session.commit()
db.session.close()
return {"success": True}

View File

@@ -0,0 +1,200 @@
<template>
<div class="border-bottom">
<div>
<button
type="button"
class="close float-right"
aria-label="Close"
@click="deleteField()"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="row">
<div class="col-md-3">
<div class="form-group">
<label>Field Type</label>
<select
class="form-control custom-select"
v-model.lazy="field.field_type"
>
<option value="text">Text Field</option>
<option value="checkbox">Checkbox</option>
</select>
<small class="form-text text-muted"
>Type of field shown to the user</small
>
</div>
</div>
<div class="col-md-9">
<div class="form-group">
<label>Field Name</label>
<input type="text" class="form-control" v-model.lazy="field.name" />
<small class="form-text text-muted">Field name</small>
</div>
</div>
<div class="col-md-12">
<div class="form-group">
<label>Field Description</label>
<input
type="text"
class="form-control"
v-model.lazy="field.description"
/>
<small id="emailHelp" class="form-text text-muted"
>Field Description</small
>
</div>
</div>
<div class="col-md-12">
<div class="form-check">
<label class="form-check-label">
<input
class="form-check-input"
type="checkbox"
v-model.lazy="field.editable"
/>
Editable by user in profile
</label>
</div>
<div class="form-check">
<label class="form-check-label">
<input
class="form-check-input"
type="checkbox"
v-model.lazy="field.required"
/>
Required on registration
</label>
</div>
<div class="form-check">
<label class="form-check-label">
<input
class="form-check-input"
type="checkbox"
v-model.lazy="field.public"
/>
Shown on public profile
</label>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col-md-12">
<div class="d-block">
<button
class="btn btn-sm btn-success btn-outlined float-right"
type="button"
@click="saveField()"
>
Save
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import CTFd from "core/CTFd";
import { ezToast } from "core/ezq";
export default {
props: {
index: Number,
initialField: Object
},
data: function() {
return {
field: this.initialField
};
},
methods: {
persistedField: function() {
// We're using Math.random() for unique IDs so new items have IDs < 1
// Real items will have an ID > 1
return this.field.id >= 1;
},
saveField: function() {
let body = this.field;
if (this.persistedField()) {
CTFd.fetch(`/api/v1/configs/fields/${this.field.id}`, {
method: "PATCH",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(body)
})
.then(response => {
return response.json();
})
.then(response => {
if (response.success === true) {
this.field = response.data;
ezToast({
title: "Success",
body: "Field has been updated!",
delay: 1000
});
}
});
} else {
CTFd.fetch(`/api/v1/configs/fields`, {
method: "POST",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(body)
})
.then(response => {
return response.json();
})
.then(response => {
if (response.success === true) {
this.field = response.data;
ezToast({
title: "Success",
body: "Field has been created!",
delay: 1000
});
}
});
}
},
deleteField: function() {
if (confirm("Are you sure you'd like to delete this field?")) {
if (this.persistedField()) {
CTFd.fetch(`/api/v1/configs/fields/${this.field.id}`, {
method: "DELETE",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
}
})
.then(response => {
return response.json();
})
.then(response => {
if (response.success === true) {
this.$emit("remove-field", this.index);
}
});
} else {
this.$emit("remove-field", this.index);
}
}
}
}
};
</script>
<style scoped></style>

View File

@@ -0,0 +1,80 @@
<template>
<div>
<!-- You can't use index as :key here b/c Vue is crazy -->
<!-- https://rimdev.io/the-v-for-key/ -->
<div class="mb-5" v-for="(field, index) in fields" :key="field.id">
<Field
:index="index"
:initialField.sync="fields[index]"
@remove-field="removeField"
/>
</div>
<div class="row">
<div class="col text-center">
<button
class="btn btn-sm btn-success btn-outlined m-auto"
type="button"
@click="addField()"
>
Add New Field
</button>
</div>
</div>
</div>
</template>
<script>
import CTFd from "core/CTFd";
import Field from "./Field.vue";
export default {
name: "FieldList",
components: {
Field
},
props: {},
data: function() {
return {
fields: []
};
},
methods: {
loadFields: function() {
CTFd.fetch("/api/v1/configs/fields?type=user", {
method: "GET",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
}
})
.then(response => {
return response.json();
})
.then(response => {
this.fields = response.data;
});
},
addField: function() {
this.fields.push({
id: Math.random(),
type: "user",
field_type: "text",
name: "",
description: "",
editable: false,
required: false,
public: false
});
},
removeField: function(index) {
this.fields.splice(index, 1);
console.log(this.fields);
}
},
created() {
this.loadFields();
}
};
</script>

View File

@@ -9,6 +9,8 @@ import $ from "jquery";
import { ezQuery, ezProgressBar, ezAlert } from "core/ezq"; import { ezQuery, ezProgressBar, ezAlert } from "core/ezq";
import CodeMirror from "codemirror"; import CodeMirror from "codemirror";
import "codemirror/mode/htmlmixed/htmlmixed.js"; import "codemirror/mode/htmlmixed/htmlmixed.js";
import Vue from "vue/dist/vue.esm.browser";
import FieldList from "../components/configs/fields/FieldList.vue";
function loadTimestamp(place, timestamp) { function loadTimestamp(place, timestamp) {
if (typeof timestamp == "string") { if (typeof timestamp == "string") {
@@ -360,4 +362,10 @@ $(() => {
$("#mail_username_password").toggle(this.checked); $("#mail_username_password").toggle(this.checked);
}) })
.change(); .change();
// Insert CommentBox element
const fieldList = Vue.extend(FieldList);
let vueContainer = document.createElement("div");
document.querySelector("#user-field-list").appendChild(vueContainer);
new fieldList({}).$mount(vueContainer);
}); });

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -23,6 +23,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link rounded-0" href="#accounts" role="tab" data-toggle="tab">Accounts</a> <a class="nav-link rounded-0" href="#accounts" role="tab" data-toggle="tab">Accounts</a>
</li> </li>
<li class="nav-item">
<a class="nav-link rounded-0" href="#fields" role="tab" data-toggle="tab">Custom Fields</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link rounded-0" href="#mlc" role="tab" data-toggle="tab">MajorLeagueCyber</a> <a class="nav-link rounded-0" href="#mlc" role="tab" data-toggle="tab">MajorLeagueCyber</a>
</li> </li>
@@ -61,6 +64,8 @@
{% include "admin/configs/accounts.html" %} {% include "admin/configs/accounts.html" %}
{% include "admin/configs/fields.html" %}
{% include "admin/configs/mlc.html" %} {% include "admin/configs/mlc.html" %}
{% include "admin/configs/settings.html" %} {% include "admin/configs/settings.html" %}

View File

@@ -0,0 +1,28 @@
<div role="tabpanel" class="tab-pane config-section" id="fields">
<form method="POST" autocomplete="off" class="w-100">
<h5>Custom Fields</h5>
<small class="form-text text-muted">
Add custom fields to get additional data from your participants
</small>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item active">
<a class="nav-link active" href="#user-fields" role="tab" data-toggle="tab">
Users
</a>
</li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="user-fields">
<div class="col-md-12 py-3">
<small>Custom user fields are shown during registration. Users can optionally edit these fields in their profile.</small>
</div>
<div id="user-field-list" class="pt-3">
</div>
</div>
</div>
</form>
</div>