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.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}
|
||||||
|
|||||||
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 { 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
@@ -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" %}
|
||||||
|
|||||||
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