mirror of
https://github.com/aljazceru/CTFd.git
synced 2025-12-18 14:34:21 +01:00
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:
@@ -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}
|
||||
|
||||
200
CTFd/themes/admin/assets/js/components/configs/fields/Field.vue
Normal file
200
CTFd/themes/admin/assets/js/components/configs/fields/Field.vue
Normal 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">×</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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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" %}
|
||||
|
||||
28
CTFd/themes/admin/templates/configs/fields.html
Normal file
28
CTFd/themes/admin/templates/configs/fields.html
Normal 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>
|
||||
Reference in New Issue
Block a user