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.cache import clear_config, clear_standings
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.fields import FieldSchema
from CTFd.utils import set_config
from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters
@@ -189,3 +190,89 @@ class Config(Resource):
clear_standings()
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 CodeMirror from "codemirror";
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) {
if (typeof timestamp == "string") {
@@ -360,4 +362,10 @@ $(() => {
$("#mail_username_password").toggle(this.checked);
})
.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">
<a class="nav-link rounded-0" href="#accounts" role="tab" data-toggle="tab">Accounts</a>
</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">
<a class="nav-link rounded-0" href="#mlc" role="tab" data-toggle="tab">MajorLeagueCyber</a>
</li>
@@ -61,6 +64,8 @@
{% include "admin/configs/accounts.html" %}
{% include "admin/configs/fields.html" %}
{% include "admin/configs/mlc.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>