mirror of
https://github.com/aljazceru/CTFd.git
synced 2025-12-17 22:14:25 +01:00
756 generic data fields (#1608)
* Adds the ability to add custom user fields for registration/profile settings * Admins can create fields that users can optionally edit * Works on #756
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 Configs, Fields, 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}
|
||||
|
||||
34
CTFd/auth.py
34
CTFd/auth.py
@@ -7,7 +7,7 @@ from flask import redirect, render_template, request, session, url_for
|
||||
from itsdangerous.exc import BadSignature, BadTimeSignature, SignatureExpired
|
||||
|
||||
from CTFd.cache import clear_team_session, clear_user_session
|
||||
from CTFd.models import Teams, Users, db
|
||||
from CTFd.models import Teams, UserFieldEntries, UserFields, Users, db
|
||||
from CTFd.utils import config, email, get_app_config, get_config
|
||||
from CTFd.utils import user as current_user
|
||||
from CTFd.utils import validators
|
||||
@@ -206,6 +206,31 @@ def register():
|
||||
valid_email = validators.validate_email(email_address)
|
||||
team_name_email_check = validators.validate_email(name)
|
||||
|
||||
# Process additional user fields
|
||||
fields = {}
|
||||
for field in UserFields.query.all():
|
||||
fields[field.id] = field
|
||||
|
||||
entries = {}
|
||||
for field_id, field in fields.items():
|
||||
value = request.form.get(f"fields[{field_id}]", "").strip()
|
||||
if field.required is True and (value is None or value == ""):
|
||||
errors.append("Please provide all required fields")
|
||||
break
|
||||
|
||||
# Handle special casing of existing profile fields
|
||||
if field.name.lower() == "affiliation":
|
||||
affiliation = value
|
||||
break
|
||||
elif field.name.lower() == "website":
|
||||
website = value
|
||||
break
|
||||
|
||||
if field.field_type == "boolean":
|
||||
entries[field_id] = bool(value)
|
||||
else:
|
||||
entries[field_id] = value
|
||||
|
||||
if country:
|
||||
try:
|
||||
validators.validate_country_code(country)
|
||||
@@ -275,6 +300,13 @@ def register():
|
||||
db.session.commit()
|
||||
db.session.flush()
|
||||
|
||||
for field_id, value in entries.items():
|
||||
entry = UserFieldEntries(
|
||||
field_id=field_id, value=value, user_id=user.id
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
|
||||
login_user(user)
|
||||
|
||||
if config.can_send_mail() and get_config(
|
||||
|
||||
@@ -4,14 +4,26 @@ from wtforms.validators import InputRequired
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
from CTFd.forms.users import attach_custom_user_fields, build_custom_user_fields
|
||||
|
||||
|
||||
class RegistrationForm(BaseForm):
|
||||
def RegistrationForm(*args, **kwargs):
|
||||
class _RegistrationForm(BaseForm):
|
||||
name = StringField("User Name", validators=[InputRequired()])
|
||||
email = EmailField("Email", validators=[InputRequired()])
|
||||
password = PasswordField("Password", validators=[InputRequired()])
|
||||
submit = SubmitField("Submit")
|
||||
|
||||
@property
|
||||
def extra(self):
|
||||
return build_custom_user_fields(
|
||||
self, include_entries=False, blacklisted_items=()
|
||||
)
|
||||
|
||||
attach_custom_user_fields(_RegistrationForm)
|
||||
|
||||
return _RegistrationForm(*args, **kwargs)
|
||||
|
||||
|
||||
class LoginForm(BaseForm):
|
||||
name = StringField("User Name or Email", validators=[InputRequired()])
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from flask import session
|
||||
from wtforms import PasswordField, SelectField, StringField
|
||||
from wtforms.fields.html5 import DateField, URLField
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
from CTFd.forms.users import attach_custom_user_fields, build_custom_user_fields
|
||||
from CTFd.utils.countries import SELECT_COUNTRIES_LIST
|
||||
|
||||
|
||||
class SettingsForm(BaseForm):
|
||||
def SettingsForm(*args, **kwargs):
|
||||
class _SettingsForm(BaseForm):
|
||||
name = StringField("User Name")
|
||||
email = StringField("Email")
|
||||
password = PasswordField("Password")
|
||||
@@ -16,6 +19,19 @@ class SettingsForm(BaseForm):
|
||||
country = SelectField("Country", choices=SELECT_COUNTRIES_LIST)
|
||||
submit = SubmitField("Submit")
|
||||
|
||||
@property
|
||||
def extra(self):
|
||||
return build_custom_user_fields(
|
||||
self,
|
||||
include_entries=True,
|
||||
fields_kwargs={"editable": True},
|
||||
field_entries_kwargs={"user_id": session["id"]},
|
||||
)
|
||||
|
||||
attach_custom_user_fields(_SettingsForm, editable=True)
|
||||
|
||||
return _SettingsForm(*args, **kwargs)
|
||||
|
||||
|
||||
class TokensForm(BaseForm):
|
||||
expiration = DateField("Expiration")
|
||||
|
||||
@@ -4,9 +4,83 @@ from wtforms.validators import InputRequired
|
||||
|
||||
from CTFd.forms import BaseForm
|
||||
from CTFd.forms.fields import SubmitField
|
||||
from CTFd.models import UserFieldEntries, UserFields
|
||||
from CTFd.utils.countries import SELECT_COUNTRIES_LIST
|
||||
|
||||
|
||||
def build_custom_user_fields(
|
||||
form_cls,
|
||||
include_entries=False,
|
||||
fields_kwargs=None,
|
||||
field_entries_kwargs=None,
|
||||
blacklisted_items=("affiliation", "website"),
|
||||
):
|
||||
"""
|
||||
Function used to reinject values back into forms for accessing by themes
|
||||
"""
|
||||
if fields_kwargs is None:
|
||||
fields_kwargs = {}
|
||||
if field_entries_kwargs is None:
|
||||
field_entries_kwargs = {}
|
||||
|
||||
fields = []
|
||||
new_fields = UserFields.query.filter_by(**fields_kwargs).all()
|
||||
user_fields = {}
|
||||
|
||||
# Only include preexisting values if asked
|
||||
if include_entries is True:
|
||||
for f in UserFieldEntries.query.filter_by(**field_entries_kwargs).all():
|
||||
user_fields[f.field_id] = f.value
|
||||
|
||||
for field in new_fields:
|
||||
if field.name.lower() in blacklisted_items:
|
||||
continue
|
||||
|
||||
form_field = getattr(form_cls, f"fields[{field.id}]")
|
||||
|
||||
# Add the field_type to the field so we know how to render it
|
||||
form_field.field_type = field.field_type
|
||||
|
||||
# Only include preexisting values if asked
|
||||
if include_entries is True:
|
||||
initial = user_fields.get(field.id, "")
|
||||
form_field.data = initial
|
||||
if form_field.render_kw:
|
||||
form_field.render_kw["data-initial"] = initial
|
||||
else:
|
||||
form_field.render_kw = {"data-initial": initial}
|
||||
|
||||
fields.append(form_field)
|
||||
return fields
|
||||
|
||||
|
||||
def attach_custom_user_fields(form_cls, **kwargs):
|
||||
"""
|
||||
Function used to attach form fields to wtforms.
|
||||
Not really a great solution but is approved by wtforms.
|
||||
|
||||
https://wtforms.readthedocs.io/en/2.3.x/specific_problems/#dynamic-form-composition
|
||||
"""
|
||||
new_fields = UserFields.query.filter_by(**kwargs).all()
|
||||
for field in new_fields:
|
||||
validators = []
|
||||
if field.required:
|
||||
validators.append(InputRequired())
|
||||
|
||||
if field.field_type == "text":
|
||||
input_field = StringField(
|
||||
field.name, description=field.description, validators=validators
|
||||
)
|
||||
elif field.field_type == "boolean":
|
||||
input_field = BooleanField(
|
||||
field.name, description=field.description, validators=validators
|
||||
)
|
||||
|
||||
setattr(
|
||||
form_cls, f"fields[{field.id}]", input_field,
|
||||
)
|
||||
|
||||
|
||||
class UserSearchForm(BaseForm):
|
||||
field = SelectField(
|
||||
"Search Field",
|
||||
@@ -40,7 +114,7 @@ class PublicUserSearchForm(BaseForm):
|
||||
submit = SubmitField("Search")
|
||||
|
||||
|
||||
class UserEditForm(BaseForm):
|
||||
class UserBaseForm(BaseForm):
|
||||
name = StringField("User Name", validators=[InputRequired()])
|
||||
email = EmailField("Email", validators=[InputRequired()])
|
||||
password = PasswordField("Password")
|
||||
@@ -54,5 +128,41 @@ class UserEditForm(BaseForm):
|
||||
submit = SubmitField("Submit")
|
||||
|
||||
|
||||
class UserCreateForm(UserEditForm):
|
||||
def UserEditForm(*args, **kwargs):
|
||||
class _UserEditForm(UserBaseForm):
|
||||
pass
|
||||
|
||||
@property
|
||||
def extra(self):
|
||||
return build_custom_user_fields(
|
||||
self,
|
||||
include_entries=True,
|
||||
fields_kwargs=None,
|
||||
field_entries_kwargs={"user_id": self.obj.id},
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Custom init to persist the obj parameter to the rest of the form
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
obj = kwargs.get("obj")
|
||||
if obj:
|
||||
self.obj = obj
|
||||
|
||||
attach_custom_user_fields(_UserEditForm)
|
||||
|
||||
return _UserEditForm(*args, **kwargs)
|
||||
|
||||
|
||||
def UserCreateForm(*args, **kwargs):
|
||||
class _UserCreateForm(UserBaseForm):
|
||||
notify = BooleanField("Email account credentials to user", default=True)
|
||||
|
||||
@property
|
||||
def extra(self):
|
||||
return build_custom_user_fields(self, include_entries=False)
|
||||
|
||||
attach_custom_user_fields(_UserCreateForm)
|
||||
|
||||
return _UserCreateForm(*args, **kwargs)
|
||||
|
||||
@@ -276,6 +276,10 @@ class Users(db.Model):
|
||||
# Relationship for Teams
|
||||
team_id = db.Column(db.Integer, db.ForeignKey("teams.id"))
|
||||
|
||||
field_entries = db.relationship(
|
||||
"UserFieldEntries", foreign_keys="UserFieldEntries.user_id", lazy="joined"
|
||||
)
|
||||
|
||||
created = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
__mapper_args__ = {"polymorphic_identity": "user", "polymorphic_on": type}
|
||||
@@ -309,6 +313,10 @@ class Users(db.Model):
|
||||
elif user_mode == "users":
|
||||
return self
|
||||
|
||||
@property
|
||||
def fields(self):
|
||||
return self.get_fields(admin=False)
|
||||
|
||||
@property
|
||||
def solves(self):
|
||||
return self.get_solves(admin=False)
|
||||
@@ -334,6 +342,12 @@ class Users(db.Model):
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_fields(self, admin=False):
|
||||
if admin:
|
||||
return self.field_entries
|
||||
|
||||
return [entry for entry in self.field_entries if entry.field.public]
|
||||
|
||||
def get_solves(self, admin=False):
|
||||
from CTFd.utils import get_config
|
||||
|
||||
@@ -781,3 +795,59 @@ class TeamComments(Comments):
|
||||
class PageComments(Comments):
|
||||
__mapper_args__ = {"polymorphic_identity": "page"}
|
||||
page_id = db.Column(db.Integer, db.ForeignKey("pages.id", ondelete="CASCADE"))
|
||||
|
||||
|
||||
class Fields(db.Model):
|
||||
__tablename__ = "fields"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.Text)
|
||||
type = db.Column(db.String(80), default="standard")
|
||||
field_type = db.Column(db.String(80))
|
||||
description = db.Column(db.Text)
|
||||
required = db.Column(db.Boolean, default=False)
|
||||
public = db.Column(db.Boolean, default=False)
|
||||
editable = db.Column(db.Boolean, default=False)
|
||||
|
||||
__mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type}
|
||||
|
||||
|
||||
class UserFields(Fields):
|
||||
__mapper_args__ = {"polymorphic_identity": "user"}
|
||||
|
||||
|
||||
class TeamFields(Fields):
|
||||
__mapper_args__ = {"polymorphic_identity": "team"}
|
||||
|
||||
|
||||
class FieldEntries(db.Model):
|
||||
__tablename__ = "field_entries"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
type = db.Column(db.String(80), default="standard")
|
||||
value = db.Column(db.JSON)
|
||||
field_id = db.Column(db.Integer, db.ForeignKey("fields.id", ondelete="CASCADE"))
|
||||
|
||||
field = db.relationship(
|
||||
"Fields", foreign_keys="FieldEntries.field_id", lazy="joined"
|
||||
)
|
||||
|
||||
__mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type}
|
||||
|
||||
@hybrid_property
|
||||
def name(self):
|
||||
return self.field.name
|
||||
|
||||
@hybrid_property
|
||||
def description(self):
|
||||
return self.field.description
|
||||
|
||||
|
||||
class UserFieldEntries(FieldEntries):
|
||||
__mapper_args__ = {"polymorphic_identity": "user"}
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"))
|
||||
user = db.relationship("Users", foreign_keys="UserFieldEntries.user_id")
|
||||
|
||||
|
||||
class TeamFieldEntries(FieldEntries):
|
||||
__mapper_args__ = {"polymorphic_identity": "team"}
|
||||
team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE"))
|
||||
team = db.relationship("Teams", foreign_keys="TeamFieldEntries.team_id")
|
||||
|
||||
24
CTFd/schemas/fields.py
Normal file
24
CTFd/schemas/fields.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from marshmallow import fields
|
||||
|
||||
from CTFd.models import Fields, UserFieldEntries, db, ma
|
||||
|
||||
|
||||
class FieldSchema(ma.ModelSchema):
|
||||
class Meta:
|
||||
model = Fields
|
||||
include_fk = True
|
||||
dump_only = ("id",)
|
||||
|
||||
|
||||
class UserFieldEntriesSchema(ma.ModelSchema):
|
||||
class Meta:
|
||||
model = UserFieldEntries
|
||||
sqla_session = db.session
|
||||
include_fk = True
|
||||
load_only = ("id",)
|
||||
exclude = ("field", "user", "user_id")
|
||||
dump_only = ("user_id", "name", "description", "type")
|
||||
|
||||
name = fields.Nested(FieldSchema, only=("name"), attribute="field")
|
||||
description = fields.Nested(FieldSchema, only=("description"), attribute="field")
|
||||
type = fields.Nested(FieldSchema, only=("field_type"), attribute="field")
|
||||
@@ -1,7 +1,10 @@
|
||||
from marshmallow import ValidationError, pre_load, validate
|
||||
from marshmallow import ValidationError, post_dump, pre_load, validate
|
||||
from marshmallow.fields import Nested
|
||||
from marshmallow_sqlalchemy import field_for
|
||||
from sqlalchemy.orm import load_only
|
||||
|
||||
from CTFd.models import Users, ma
|
||||
from CTFd.models import UserFieldEntries, UserFields, Users, ma
|
||||
from CTFd.schemas.fields import UserFieldEntriesSchema
|
||||
from CTFd.utils import get_config, string_types
|
||||
from CTFd.utils.crypto import verify_password
|
||||
from CTFd.utils.email import check_email_is_whitelisted
|
||||
@@ -49,6 +52,9 @@ class UserSchema(ma.ModelSchema):
|
||||
)
|
||||
country = field_for(Users, "country", validate=[validate_country_code])
|
||||
password = field_for(Users, "password")
|
||||
fields = Nested(
|
||||
UserFieldEntriesSchema, partial=True, many=True, attribute="field_entries"
|
||||
)
|
||||
|
||||
@pre_load
|
||||
def validate_name(self, data):
|
||||
@@ -180,6 +186,116 @@ class UserSchema(ma.ModelSchema):
|
||||
data.pop("password", None)
|
||||
data.pop("confirm", None)
|
||||
|
||||
@pre_load
|
||||
def validate_fields(self, data):
|
||||
"""
|
||||
This validator is used to only allow users to update the field entry for their user.
|
||||
It's not possible to exclude it because without the PK Marshmallow cannot load the right instance
|
||||
"""
|
||||
fields = data.get("fields")
|
||||
if fields is None:
|
||||
return
|
||||
|
||||
current_user = get_current_user()
|
||||
|
||||
if is_admin():
|
||||
user_id = data.get("id")
|
||||
if user_id:
|
||||
target_user = Users.query.filter_by(id=data["id"]).first()
|
||||
else:
|
||||
target_user = current_user
|
||||
|
||||
provided_ids = []
|
||||
for f in fields:
|
||||
f.pop("id", None)
|
||||
field_id = f.get("field_id")
|
||||
|
||||
# # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce
|
||||
field = UserFields.query.filter_by(id=field_id).first_or_404()
|
||||
|
||||
# Get the existing field entry if one exists
|
||||
entry = UserFieldEntries.query.filter_by(
|
||||
field_id=field.id, user_id=target_user.id
|
||||
).first()
|
||||
if entry:
|
||||
f["id"] = entry.id
|
||||
provided_ids.append(entry.id)
|
||||
|
||||
# Extremely dirty hack to prevent deleting previously provided data.
|
||||
# This needs a better soln.
|
||||
entries = (
|
||||
UserFieldEntries.query.options(load_only("id"))
|
||||
.filter_by(user_id=target_user.id)
|
||||
.all()
|
||||
)
|
||||
for entry in entries:
|
||||
if entry.id not in provided_ids:
|
||||
fields.append({"id": entry.id})
|
||||
else:
|
||||
provided_ids = []
|
||||
for f in fields:
|
||||
# Remove any existing set
|
||||
f.pop("id", None)
|
||||
field_id = f.get("field_id")
|
||||
|
||||
# # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce
|
||||
field = UserFields.query.filter_by(id=field_id).first_or_404()
|
||||
|
||||
if field.editable is False:
|
||||
raise ValidationError(
|
||||
f"Field {field.name} cannot be editted", field_names=["fields"]
|
||||
)
|
||||
|
||||
# Get the existing field entry if one exists
|
||||
entry = UserFieldEntries.query.filter_by(
|
||||
field_id=field.id, user_id=current_user.id
|
||||
).first()
|
||||
|
||||
if entry:
|
||||
f["id"] = entry.id
|
||||
provided_ids.append(entry.id)
|
||||
|
||||
# Extremely dirty hack to prevent deleting previously provided data.
|
||||
# This needs a better soln.
|
||||
entries = (
|
||||
UserFieldEntries.query.options(load_only("id"))
|
||||
.filter_by(user_id=current_user.id)
|
||||
.all()
|
||||
)
|
||||
for entry in entries:
|
||||
if entry.id not in provided_ids:
|
||||
fields.append({"id": entry.id})
|
||||
|
||||
@post_dump
|
||||
def process_fields(self, data):
|
||||
"""
|
||||
Handle permissions levels for fields.
|
||||
This is post_dump to manipulate JSON instead of the raw db object
|
||||
|
||||
Admins can see all fields.
|
||||
Users (self) can see their edittable and public fields
|
||||
Public (user) can only see public fields
|
||||
"""
|
||||
# Gather all possible fields
|
||||
removed_field_ids = []
|
||||
fields = UserFields.query.all()
|
||||
|
||||
# Select fields for removal based on current view and properties of the field
|
||||
for field in fields:
|
||||
if self.view == "user":
|
||||
if field.public is False:
|
||||
removed_field_ids.append(field.id)
|
||||
elif self.view == "self":
|
||||
if field.editable is False and field.public is False:
|
||||
removed_field_ids.append(field.id)
|
||||
|
||||
# Rebuild fuilds
|
||||
fields = data.get("fields")
|
||||
if fields:
|
||||
data["fields"] = [
|
||||
field for field in fields if field["field_id"] not in removed_field_ids
|
||||
]
|
||||
|
||||
views = {
|
||||
"user": [
|
||||
"website",
|
||||
@@ -189,6 +305,7 @@ class UserSchema(ma.ModelSchema):
|
||||
"bracket",
|
||||
"id",
|
||||
"oauth_id",
|
||||
"fields",
|
||||
],
|
||||
"self": [
|
||||
"website",
|
||||
@@ -200,6 +317,7 @@ class UserSchema(ma.ModelSchema):
|
||||
"id",
|
||||
"oauth_id",
|
||||
"password",
|
||||
"fields",
|
||||
],
|
||||
"admin": [
|
||||
"website",
|
||||
@@ -217,6 +335,7 @@ class UserSchema(ma.ModelSchema):
|
||||
"password",
|
||||
"type",
|
||||
"verified",
|
||||
"fields",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -226,5 +345,6 @@ class UserSchema(ma.ModelSchema):
|
||||
kwargs["only"] = self.views[view]
|
||||
elif isinstance(view, list):
|
||||
kwargs["only"] = view
|
||||
self.view = view
|
||||
|
||||
super(UserSchema, self).__init__(*args, **kwargs)
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,19 @@ function createUser(event) {
|
||||
event.preventDefault();
|
||||
const params = $("#user-info-create-form").serializeJSON(true);
|
||||
|
||||
params.fields = [];
|
||||
|
||||
for (const property in params) {
|
||||
if (property.match(/fields\[\d+\]/)) {
|
||||
let field = {};
|
||||
let id = parseInt(property.slice(7, -1));
|
||||
field["field_id"] = id;
|
||||
field["value"] = params[property];
|
||||
params.fields.push(field);
|
||||
delete params[property];
|
||||
}
|
||||
}
|
||||
|
||||
// Move the notify value into a GET param
|
||||
let url = "/api/v1/users";
|
||||
let notify = params.notify;
|
||||
@@ -57,6 +70,19 @@ function updateUser(event) {
|
||||
event.preventDefault();
|
||||
const params = $("#user-info-edit-form").serializeJSON(true);
|
||||
|
||||
params.fields = [];
|
||||
|
||||
for (const property in params) {
|
||||
if (property.match(/fields\[\d+\]/)) {
|
||||
let field = {};
|
||||
let id = parseInt(property.slice(7, -1));
|
||||
field["field_id"] = id;
|
||||
field["value"] = params[property];
|
||||
params.fields.push(field);
|
||||
delete params[property];
|
||||
}
|
||||
}
|
||||
|
||||
CTFd.fetch("/api/v1/users/" + window.USER_ID, {
|
||||
method: "PATCH",
|
||||
credentials: "same-origin",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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>
|
||||
46
CTFd/themes/admin/templates/macros/forms.html
Normal file
46
CTFd/themes/admin/templates/macros/forms.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% macro render_extra_fields(fields, show_labels=True, show_optionals=True, show_descriptions=True) -%}
|
||||
{% for field in fields %}
|
||||
<div class="form-group">
|
||||
{% if field.field_type == "text" %}
|
||||
{% if show_labels %}
|
||||
{{ field.label }}
|
||||
{% endif %}
|
||||
|
||||
{% if show_optionals %}
|
||||
{% if field.flags.required is false %}
|
||||
<small class="float-right text-muted">Optional</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{{ field(class="form-control") }}
|
||||
|
||||
{% if show_descriptions %}
|
||||
{% if field.description %}
|
||||
<small class="form-text text-muted">
|
||||
{{ field.description }}
|
||||
</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% elif field.field_type == "boolean" %}
|
||||
<div class="form-check">
|
||||
{{ field(class="form-check-input") }}
|
||||
{{ field.label(class="form-check-label") }}
|
||||
|
||||
{% if show_optionals %}
|
||||
{% if field.flags.required is false %}
|
||||
<sup class="text-muted">Optional</sup>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if show_descriptions %}
|
||||
{% if field.description %}
|
||||
<small class="form-text text-muted">
|
||||
{{ field.description }}
|
||||
</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{%- endmacro %}
|
||||
@@ -1,4 +1,5 @@
|
||||
{% with form = Forms.users.UserCreateForm() %}
|
||||
{% from "admin/macros/forms.html" import render_extra_fields %}
|
||||
<form id="user-info-create-form" method="POST">
|
||||
<div class="form-group">
|
||||
{{ form.name.label }}
|
||||
@@ -14,16 +15,22 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.website.label }}
|
||||
<small class="float-right text-muted align-text-bottom">Optional</small>
|
||||
{{ form.website(class="form-control") }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.affiliation.label }}
|
||||
<small class="float-right text-muted align-text-bottom">Optional</small>
|
||||
{{ form.affiliation(class="form-control") }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.country.label }}
|
||||
<small class="float-right text-muted align-text-bottom">Optional</small>
|
||||
{{ form.country(class="form-control custom-select") }}
|
||||
</div>
|
||||
|
||||
{{ render_extra_fields(form.extra) }}
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
{{ form.type(class="form-control form-inline custom-select", id="type-select") }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% with form = Forms.users.UserEditForm(obj=user) %}
|
||||
{% from "admin/macros/forms.html" import render_extra_fields %}
|
||||
<form id="user-info-edit-form">
|
||||
<div class="form-group">
|
||||
{{ form.name.label }}
|
||||
@@ -24,6 +25,9 @@
|
||||
{{ form.country.label }}
|
||||
{{ form.country(class="form-control custom-select") }}
|
||||
</div>
|
||||
|
||||
{{ render_extra_fields(form.extra) }}
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
{{ form.type(class="form-control form-inline custom-select", id="type-select") }}
|
||||
|
||||
@@ -132,6 +132,12 @@
|
||||
</h2>
|
||||
{% endif %}
|
||||
|
||||
{% for field in user.get_fields(admin=true) %}
|
||||
<h3 class="d-block">
|
||||
{{ field.name }}: {{ field.value }}
|
||||
</h3>
|
||||
{% endfor %}
|
||||
|
||||
<h3 id="team-place" class="text-center">
|
||||
{% if place %}
|
||||
{{ place }}
|
||||
|
||||
@@ -24,6 +24,19 @@ function profileUpdate(event) {
|
||||
const $form = $(this);
|
||||
let params = $form.serializeJSON(true);
|
||||
|
||||
params.fields = [];
|
||||
|
||||
for (const property in params) {
|
||||
if (property.match(/fields\[\d+\]/)) {
|
||||
let field = {};
|
||||
let id = parseInt(property.slice(7, -1));
|
||||
field["field_id"] = id;
|
||||
field["value"] = params[property];
|
||||
params.fields.push(field);
|
||||
delete params[property];
|
||||
}
|
||||
}
|
||||
|
||||
CTFd.api.patch_user_private({}, params).then(response => {
|
||||
if (response.success) {
|
||||
$("#results").html(success_template);
|
||||
|
||||
@@ -26,7 +26,7 @@ $.fn.serializeJSON = function(omit_nulls) {
|
||||
if (x.value !== null && x.value !== "") {
|
||||
params[x.name] = x.value;
|
||||
} else {
|
||||
let input = form.find(`:input[name=${x.name}]`);
|
||||
let input = form.find(`:input[name='${x.name}']`);
|
||||
if (input.data("initial") !== input.val()) {
|
||||
params[x.name] = x.value;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
CTFd/themes/core/static/js/pages/main.min.js
vendored
2
CTFd/themes/core/static/js/pages/main.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -162,7 +162,7 @@
|
||||
/***/ (function(module, exports, __webpack_require__) {
|
||||
|
||||
;
|
||||
eval("\n\n__webpack_require__(/*! ./main */ \"./CTFd/themes/core/assets/js/pages/main.js\");\n\nvar _utils = __webpack_require__(/*! ../utils */ \"./CTFd/themes/core/assets/js/utils.js\");\n\nvar _jquery = _interopRequireDefault(__webpack_require__(/*! jquery */ \"./node_modules/jquery/dist/jquery.js\"));\n\nvar _CTFd = _interopRequireDefault(__webpack_require__(/*! ../CTFd */ \"./CTFd/themes/core/assets/js/CTFd.js\"));\n\nvar _ezq = __webpack_require__(/*! ../ezq */ \"./CTFd/themes/core/assets/js/ezq.js\");\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nvar error_template = '<div class=\"alert alert-danger alert-dismissable\" role=\"alert\">\\n' + ' <span class=\"sr-only\">Error:</span>\\n' + \" {0}\\n\" + ' <button type=\"button\" class=\"close\" data-dismiss=\"alert\" aria-label=\"Close\"><span aria-hidden=\"true\">×</span></button>\\n' + \"</div>\";\nvar success_template = '<div class=\"alert alert-success alert-dismissable submit-row\" role=\"alert\">\\n' + \" <strong>Success!</strong>\\n\" + \" Your profile has been updated\\n\" + ' <button type=\"button\" class=\"close\" data-dismiss=\"alert\" aria-label=\"Close\"><span aria-hidden=\"true\">×</span></button>\\n' + \"</div>\";\n\nfunction profileUpdate(event) {\n event.preventDefault();\n (0, _jquery.default)(\"#results\").empty();\n var $form = (0, _jquery.default)(this);\n var params = $form.serializeJSON(true);\n\n _CTFd.default.api.patch_user_private({}, params).then(function (response) {\n if (response.success) {\n (0, _jquery.default)(\"#results\").html(success_template);\n } else if (\"errors\" in response) {\n Object.keys(response.errors).map(function (error) {\n var i = $form.find(\"input[name={0}]\".format(error));\n var input = (0, _jquery.default)(i);\n input.addClass(\"input-filled-invalid\");\n input.removeClass(\"input-filled-valid\");\n var error_msg = response.errors[error];\n (0, _jquery.default)(\"#results\").append(error_template.format(error_msg));\n });\n }\n });\n}\n\nfunction tokenGenerate(event) {\n event.preventDefault();\n var $form = (0, _jquery.default)(this);\n var params = $form.serializeJSON(true);\n\n _CTFd.default.fetch(\"/api/v1/tokens\", {\n method: \"POST\",\n body: JSON.stringify(params)\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (response.success) {\n var body = (0, _jquery.default)(\"\\n <p>Please copy your API Key, it won't be shown again!</p>\\n <div class=\\\"input-group mb-3\\\">\\n <input type=\\\"text\\\" id=\\\"user-token-result\\\" class=\\\"form-control\\\" value=\\\"\".concat(response.data.value, \"\\\" readonly>\\n <div class=\\\"input-group-append\\\">\\n <button class=\\\"btn btn-outline-secondary\\\" type=\\\"button\\\">\\n <i class=\\\"fas fa-clipboard\\\"></i>\\n </button>\\n </div>\\n </div>\\n \"));\n body.find(\"button\").click(function (event) {\n (0, _utils.copyToClipboard)(event, \"#user-token-result\");\n });\n (0, _ezq.ezAlert)({\n title: \"API Key Generated\",\n body: body,\n button: \"Got it!\",\n large: true\n });\n }\n });\n}\n\nfunction deleteToken(event) {\n event.preventDefault();\n var $elem = (0, _jquery.default)(this);\n var id = $elem.data(\"token-id\");\n (0, _ezq.ezQuery)({\n title: \"Delete Token\",\n body: \"Are you sure you want to delete this token?\",\n success: function success() {\n _CTFd.default.fetch(\"/api/v1/tokens/\" + id, {\n method: \"DELETE\"\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (response.success) {\n $elem.parent().parent().remove();\n }\n });\n }\n });\n}\n\n(0, _jquery.default)(function () {\n (0, _jquery.default)(\"#user-profile-form\").submit(profileUpdate);\n (0, _jquery.default)(\"#user-token-form\").submit(tokenGenerate);\n (0, _jquery.default)(\".delete-token\").click(deleteToken);\n (0, _jquery.default)(\".nav-pills a\").click(function (_event) {\n window.location.hash = this.hash;\n }); // Load location hash\n\n var hash = window.location.hash;\n\n if (hash) {\n hash = hash.replace(\"<>[]'\\\"\", \"\");\n (0, _jquery.default)('.nav-pills a[href=\"' + hash + '\"]').tab(\"show\");\n }\n});\n\n//# sourceURL=webpack:///./CTFd/themes/core/assets/js/pages/settings.js?");
|
||||
eval("\n\n__webpack_require__(/*! ./main */ \"./CTFd/themes/core/assets/js/pages/main.js\");\n\nvar _utils = __webpack_require__(/*! ../utils */ \"./CTFd/themes/core/assets/js/utils.js\");\n\nvar _jquery = _interopRequireDefault(__webpack_require__(/*! jquery */ \"./node_modules/jquery/dist/jquery.js\"));\n\nvar _CTFd = _interopRequireDefault(__webpack_require__(/*! ../CTFd */ \"./CTFd/themes/core/assets/js/CTFd.js\"));\n\nvar _ezq = __webpack_require__(/*! ../ezq */ \"./CTFd/themes/core/assets/js/ezq.js\");\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nvar error_template = '<div class=\"alert alert-danger alert-dismissable\" role=\"alert\">\\n' + ' <span class=\"sr-only\">Error:</span>\\n' + \" {0}\\n\" + ' <button type=\"button\" class=\"close\" data-dismiss=\"alert\" aria-label=\"Close\"><span aria-hidden=\"true\">×</span></button>\\n' + \"</div>\";\nvar success_template = '<div class=\"alert alert-success alert-dismissable submit-row\" role=\"alert\">\\n' + \" <strong>Success!</strong>\\n\" + \" Your profile has been updated\\n\" + ' <button type=\"button\" class=\"close\" data-dismiss=\"alert\" aria-label=\"Close\"><span aria-hidden=\"true\">×</span></button>\\n' + \"</div>\";\n\nfunction profileUpdate(event) {\n event.preventDefault();\n (0, _jquery.default)(\"#results\").empty();\n var $form = (0, _jquery.default)(this);\n var params = $form.serializeJSON(true);\n params.fields = [];\n\n for (var property in params) {\n if (property.match(/fields\\[\\d+\\]/)) {\n var field = {};\n var id = parseInt(property.slice(7, -1));\n field[\"field_id\"] = id;\n field[\"value\"] = params[property];\n params.fields.push(field);\n delete params[property];\n }\n }\n\n _CTFd.default.api.patch_user_private({}, params).then(function (response) {\n if (response.success) {\n (0, _jquery.default)(\"#results\").html(success_template);\n } else if (\"errors\" in response) {\n Object.keys(response.errors).map(function (error) {\n var i = $form.find(\"input[name={0}]\".format(error));\n var input = (0, _jquery.default)(i);\n input.addClass(\"input-filled-invalid\");\n input.removeClass(\"input-filled-valid\");\n var error_msg = response.errors[error];\n (0, _jquery.default)(\"#results\").append(error_template.format(error_msg));\n });\n }\n });\n}\n\nfunction tokenGenerate(event) {\n event.preventDefault();\n var $form = (0, _jquery.default)(this);\n var params = $form.serializeJSON(true);\n\n _CTFd.default.fetch(\"/api/v1/tokens\", {\n method: \"POST\",\n body: JSON.stringify(params)\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (response.success) {\n var body = (0, _jquery.default)(\"\\n <p>Please copy your API Key, it won't be shown again!</p>\\n <div class=\\\"input-group mb-3\\\">\\n <input type=\\\"text\\\" id=\\\"user-token-result\\\" class=\\\"form-control\\\" value=\\\"\".concat(response.data.value, \"\\\" readonly>\\n <div class=\\\"input-group-append\\\">\\n <button class=\\\"btn btn-outline-secondary\\\" type=\\\"button\\\">\\n <i class=\\\"fas fa-clipboard\\\"></i>\\n </button>\\n </div>\\n </div>\\n \"));\n body.find(\"button\").click(function (event) {\n (0, _utils.copyToClipboard)(event, \"#user-token-result\");\n });\n (0, _ezq.ezAlert)({\n title: \"API Key Generated\",\n body: body,\n button: \"Got it!\",\n large: true\n });\n }\n });\n}\n\nfunction deleteToken(event) {\n event.preventDefault();\n var $elem = (0, _jquery.default)(this);\n var id = $elem.data(\"token-id\");\n (0, _ezq.ezQuery)({\n title: \"Delete Token\",\n body: \"Are you sure you want to delete this token?\",\n success: function success() {\n _CTFd.default.fetch(\"/api/v1/tokens/\" + id, {\n method: \"DELETE\"\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (response.success) {\n $elem.parent().parent().remove();\n }\n });\n }\n });\n}\n\n(0, _jquery.default)(function () {\n (0, _jquery.default)(\"#user-profile-form\").submit(profileUpdate);\n (0, _jquery.default)(\"#user-token-form\").submit(tokenGenerate);\n (0, _jquery.default)(\".delete-token\").click(deleteToken);\n (0, _jquery.default)(\".nav-pills a\").click(function (_event) {\n window.location.hash = this.hash;\n }); // Load location hash\n\n var hash = window.location.hash;\n\n if (hash) {\n hash = hash.replace(\"<>[]'\\\"\", \"\");\n (0, _jquery.default)('.nav-pills a[href=\"' + hash + '\"]').tab(\"show\");\n }\n});\n\n//# sourceURL=webpack:///./CTFd/themes/core/assets/js/pages/settings.js?");
|
||||
|
||||
/***/ })
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
46
CTFd/themes/core/templates/macros/forms.html
Normal file
46
CTFd/themes/core/templates/macros/forms.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% macro render_extra_fields(fields, show_labels=True, show_optionals=True, show_descriptions=True) -%}
|
||||
{% for field in fields %}
|
||||
<div class="form-group">
|
||||
{% if field.field_type == "text" %}
|
||||
{% if show_labels %}
|
||||
<b>{{ field.label }}</b>
|
||||
{% endif %}
|
||||
|
||||
{% if show_optionals %}
|
||||
{% if field.flags.required is false %}
|
||||
<small class="float-right text-muted">Optional</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{{ field(class="form-control") }}
|
||||
|
||||
{% if show_descriptions %}
|
||||
{% if field.description %}
|
||||
<small class="form-text text-muted">
|
||||
{{ field.description }}
|
||||
</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% elif field.field_type == "boolean" %}
|
||||
<div class="form-check">
|
||||
{{ field(class="form-check-input") }}
|
||||
{{ field.label(class="form-check-label") }}
|
||||
|
||||
{% if show_optionals %}
|
||||
{% if field.flags.required is false %}
|
||||
<sup class="text-muted">Optional</sup>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if show_descriptions %}
|
||||
{% if field.description %}
|
||||
<small class="form-text text-muted">
|
||||
{{ field.description }}
|
||||
</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{%- endmacro %}
|
||||
@@ -23,20 +23,33 @@
|
||||
{% endif %}
|
||||
|
||||
{% with form = Forms.auth.RegistrationForm() %}
|
||||
{% from "macros/forms.html" import render_extra_fields %}
|
||||
<form method="post" accept-charset="utf-8" autocomplete="off" role="form">
|
||||
<div class="form-group">
|
||||
<b>{{ form.name.label }}</b>
|
||||
{{ form.name(class="form-control", value=name) }}
|
||||
<small class="form-text text-muted">
|
||||
Your username on the site
|
||||
</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<b>{{ form.email.label }}</b>
|
||||
{{ form.email(class="form-control", value=email) }}
|
||||
<small class="form-text text-muted">
|
||||
Never shown to the public
|
||||
</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<b>{{ form.password.label }}</b>
|
||||
{{ form.password(class="form-control", value=password) }}
|
||||
<small class="form-text text-muted">
|
||||
Password used to log into your account
|
||||
</small>
|
||||
</div>
|
||||
{{ form.nonce() }}
|
||||
|
||||
{{ render_extra_fields(form.extra) }}
|
||||
|
||||
<div class="row pt-3">
|
||||
<div class="col-md-12">
|
||||
{{ form.submit(class="btn btn-md btn-primary btn-outlined float-right") }}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
{% include "components/errors.html" %}
|
||||
|
||||
{% with form = Forms.self.SettingsForm(country=country) %}
|
||||
{% from "macros/forms.html" import render_extra_fields %}
|
||||
<form id="user-profile-form" method="post" accept-charset="utf-8" autocomplete="off" role="form"
|
||||
class="form-horizontal">
|
||||
<div class="form-group">
|
||||
@@ -60,6 +61,10 @@
|
||||
{{ form.country(class="form-control custom-select", value=country) }}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
{{ render_extra_fields(form.extra) }}
|
||||
|
||||
<div id="results" class="form-group">
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,6 +39,12 @@
|
||||
</h3>
|
||||
{% endif %}
|
||||
|
||||
{% for field in user.fields %}
|
||||
<h3 class="d-inline-block">
|
||||
{{ field.name }}: {{ field.value }}
|
||||
</h3>
|
||||
{% endfor %}
|
||||
|
||||
<div>
|
||||
<h2 class="text-center">
|
||||
{% if account.place %}
|
||||
|
||||
@@ -39,6 +39,12 @@
|
||||
</h3>
|
||||
{% endif %}
|
||||
|
||||
{% for field in user.fields %}
|
||||
<h3 class="d-block">
|
||||
{{ field.name }}: {{ field.value }}
|
||||
</h3>
|
||||
{% endfor %}
|
||||
|
||||
<div>
|
||||
<h2 class="text-center">
|
||||
{% if account.place %}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Add Fields and FieldEntries tables
|
||||
|
||||
Revision ID: 75e8ab9a0014
|
||||
Revises: 0366ba6575ca
|
||||
Create Date: 2020-08-19 00:36:17.579497
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "75e8ab9a0014"
|
||||
down_revision = "0366ba6575ca"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"fields",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=True),
|
||||
sa.Column("type", sa.String(length=80), nullable=True),
|
||||
sa.Column("field_type", sa.String(length=80), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("required", sa.Boolean(), nullable=True),
|
||||
sa.Column("public", sa.Boolean(), nullable=True),
|
||||
sa.Column("editable", sa.Boolean(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_table(
|
||||
"field_entries",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("type", sa.String(length=80), nullable=True),
|
||||
sa.Column("value", sa.JSON(), nullable=True),
|
||||
sa.Column("field_id", sa.Integer(), nullable=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("team_id", sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["field_id"], ["fields.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["team_id"], ["teams.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("field_entries")
|
||||
op.drop_table("fields")
|
||||
# ### end Alembic commands ###
|
||||
39
tests/admin/test_fields.py
Normal file
39
tests/admin/test_fields.py
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from tests.helpers import (
|
||||
create_ctfd,
|
||||
destroy_ctfd,
|
||||
gen_field,
|
||||
login_as_user,
|
||||
register_user,
|
||||
)
|
||||
|
||||
|
||||
def test_admin_view_fields():
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
register_user(app)
|
||||
|
||||
gen_field(
|
||||
app.db, name="CustomField1", required=True, public=True, editable=True
|
||||
)
|
||||
gen_field(
|
||||
app.db, name="CustomField2", required=False, public=True, editable=True
|
||||
)
|
||||
gen_field(
|
||||
app.db, name="CustomField3", required=False, public=False, editable=True
|
||||
)
|
||||
gen_field(
|
||||
app.db, name="CustomField4", required=False, public=False, editable=False
|
||||
)
|
||||
|
||||
with login_as_user(app, name="admin") as admin:
|
||||
# Admins should see all user fields regardless of public or editable
|
||||
r = admin.get("/admin/users/2")
|
||||
resp = r.get_data(as_text=True)
|
||||
assert "CustomField1" in resp
|
||||
assert "CustomField2" in resp
|
||||
assert "CustomField3" in resp
|
||||
assert "CustomField4" in resp
|
||||
destroy_ctfd(app)
|
||||
253
tests/api/v1/test_fields.py
Normal file
253
tests/api/v1/test_fields.py
Normal file
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from CTFd.models import Fields, UserFieldEntries
|
||||
from tests.helpers import (
|
||||
create_ctfd,
|
||||
destroy_ctfd,
|
||||
gen_field,
|
||||
login_as_user,
|
||||
register_user,
|
||||
)
|
||||
|
||||
|
||||
def test_api_custom_fields():
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
register_user(app)
|
||||
gen_field(app.db, name="CustomField1")
|
||||
gen_field(app.db, name="CustomField2")
|
||||
|
||||
with login_as_user(app) as user:
|
||||
r = user.get("/api/v1/configs/fields", json="")
|
||||
assert r.status_code == 403
|
||||
|
||||
with login_as_user(app, name="admin") as admin:
|
||||
r = admin.get("/api/v1/configs/fields", json="")
|
||||
resp = r.get_json()
|
||||
|
||||
assert resp == {
|
||||
"success": True,
|
||||
"data": [
|
||||
{
|
||||
"public": True,
|
||||
"required": True,
|
||||
"type": "user",
|
||||
"editable": True,
|
||||
"id": 1,
|
||||
"field_type": "text",
|
||||
"description": "CustomFieldDescription",
|
||||
"name": "CustomField1",
|
||||
},
|
||||
{
|
||||
"public": True,
|
||||
"required": True,
|
||||
"type": "user",
|
||||
"editable": True,
|
||||
"id": 2,
|
||||
"field_type": "text",
|
||||
"description": "CustomFieldDescription",
|
||||
"name": "CustomField2",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
r = admin.post(
|
||||
"/api/v1/configs/fields",
|
||||
json={
|
||||
"public": True,
|
||||
"required": True,
|
||||
"editable": True,
|
||||
"id": 2,
|
||||
"type": "user",
|
||||
"field_type": "text",
|
||||
"description": "CustomFieldDescription",
|
||||
"name": "CustomField3",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
r = admin.get("/api/v1/configs/fields", json="")
|
||||
resp = r.get_json()
|
||||
assert resp == {
|
||||
"success": True,
|
||||
"data": [
|
||||
{
|
||||
"public": True,
|
||||
"required": True,
|
||||
"type": "user",
|
||||
"editable": True,
|
||||
"id": 1,
|
||||
"field_type": "text",
|
||||
"description": "CustomFieldDescription",
|
||||
"name": "CustomField1",
|
||||
},
|
||||
{
|
||||
"public": True,
|
||||
"required": True,
|
||||
"type": "user",
|
||||
"editable": True,
|
||||
"id": 2,
|
||||
"field_type": "text",
|
||||
"description": "CustomFieldDescription",
|
||||
"name": "CustomField2",
|
||||
},
|
||||
{
|
||||
"public": True,
|
||||
"required": True,
|
||||
"editable": True,
|
||||
"id": 3,
|
||||
"type": "user",
|
||||
"field_type": "text",
|
||||
"description": "CustomFieldDescription",
|
||||
"name": "CustomField3",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
r = admin.patch(
|
||||
"/api/v1/configs/fields/3",
|
||||
json={
|
||||
"public": False,
|
||||
"required": False,
|
||||
"editable": False,
|
||||
"id": 4,
|
||||
"type": "user",
|
||||
"field_type": "text",
|
||||
"description": "CustomFieldDescription",
|
||||
"name": "PatchedCustomField3",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.get_json()["data"] == {
|
||||
"public": False,
|
||||
"required": False,
|
||||
"editable": False,
|
||||
"id": 3,
|
||||
"type": "user",
|
||||
"field_type": "text",
|
||||
"description": "CustomFieldDescription",
|
||||
"name": "PatchedCustomField3",
|
||||
}
|
||||
|
||||
r = admin.get("/api/v1/configs/fields/3", json="")
|
||||
assert r.status_code == 200
|
||||
assert r.get_json()["data"] == {
|
||||
"public": False,
|
||||
"required": False,
|
||||
"editable": False,
|
||||
"id": 3,
|
||||
"type": "user",
|
||||
"field_type": "text",
|
||||
"description": "CustomFieldDescription",
|
||||
"name": "PatchedCustomField3",
|
||||
}
|
||||
|
||||
r = admin.delete("/api/v1/configs/fields/3", json="")
|
||||
assert r.status_code == 200
|
||||
|
||||
r = admin.get("/api/v1/configs/fields/3", json="")
|
||||
assert r.status_code == 404
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_api_self_fields_permissions():
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
gen_field(app.db, name="CustomField1", public=False, editable=False)
|
||||
gen_field(app.db, name="CustomField2", public=True, editable=True)
|
||||
|
||||
with app.test_client() as client:
|
||||
client.get("/register")
|
||||
with client.session_transaction() as sess:
|
||||
data = {
|
||||
"name": "user",
|
||||
"email": "user@ctfd.io",
|
||||
"password": "password",
|
||||
"nonce": sess.get("nonce"),
|
||||
"fields[1]": "CustomValue1",
|
||||
"fields[2]": "CustomValue2",
|
||||
}
|
||||
r = client.post("/register", data=data)
|
||||
with client.session_transaction() as sess:
|
||||
assert sess["id"]
|
||||
|
||||
with login_as_user(app) as user, login_as_user(app, name="admin") as admin:
|
||||
r = user.get("/api/v1/users/me")
|
||||
resp = r.get_json()
|
||||
assert resp["data"]["fields"] == [
|
||||
{
|
||||
"value": "CustomValue2",
|
||||
"name": "CustomField2",
|
||||
"description": "CustomFieldDescription",
|
||||
"type": "text",
|
||||
"field_id": 2,
|
||||
}
|
||||
]
|
||||
|
||||
r = admin.get("/api/v1/users/2")
|
||||
resp = r.get_json()
|
||||
assert len(resp["data"]["fields"]) == 2
|
||||
|
||||
field = Fields.query.filter_by(id=1).first()
|
||||
field.public = True
|
||||
app.db.session.commit()
|
||||
r = user.get("/api/v1/users/me")
|
||||
resp = r.get_json()
|
||||
assert len(resp["data"]["fields"]) == 2
|
||||
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_partial_field_update():
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
register_user(app)
|
||||
gen_field(app.db, name="CustomField1")
|
||||
gen_field(app.db, name="CustomField2")
|
||||
|
||||
with login_as_user(app) as user:
|
||||
r = user.patch(
|
||||
"/api/v1/users/me",
|
||||
json={
|
||||
"fields": [
|
||||
{"field_id": 1, "value": "CustomValue1"},
|
||||
{"field_id": 2, "value": "CustomValue2"},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert UserFieldEntries.query.count() == 2
|
||||
|
||||
r = user.patch(
|
||||
"/api/v1/users/me",
|
||||
json={"fields": [{"field_id": 2, "value": "NewCustomValue2"}]},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert UserFieldEntries.query.count() == 2
|
||||
assert (
|
||||
UserFieldEntries.query.filter_by(field_id=1, user_id=2).first().value
|
||||
== "CustomValue1"
|
||||
)
|
||||
assert (
|
||||
UserFieldEntries.query.filter_by(field_id=2, user_id=2).first().value
|
||||
== "NewCustomValue2"
|
||||
)
|
||||
|
||||
with login_as_user(app, name="admin") as admin:
|
||||
r = admin.patch(
|
||||
"/api/v1/users/2",
|
||||
json={"fields": [{"field_id": 2, "value": "AdminNewCustomValue2"}]},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert UserFieldEntries.query.count() == 2
|
||||
assert (
|
||||
UserFieldEntries.query.filter_by(field_id=1, user_id=2).first().value
|
||||
== "CustomValue1"
|
||||
)
|
||||
assert (
|
||||
UserFieldEntries.query.filter_by(field_id=2, user_id=2).first().value
|
||||
== "AdminNewCustomValue2"
|
||||
)
|
||||
|
||||
destroy_ctfd(app)
|
||||
@@ -22,6 +22,7 @@ from CTFd.models import (
|
||||
Challenges,
|
||||
Comments,
|
||||
Fails,
|
||||
Fields,
|
||||
Files,
|
||||
Flags,
|
||||
Hints,
|
||||
@@ -458,6 +459,30 @@ def gen_comment(db, content="comment", author_id=None, type="challenge", **kwarg
|
||||
return comment
|
||||
|
||||
|
||||
def gen_field(
|
||||
db,
|
||||
name="CustomField",
|
||||
type="user",
|
||||
field_type="text",
|
||||
description="CustomFieldDescription",
|
||||
required=True,
|
||||
public=True,
|
||||
editable=True,
|
||||
):
|
||||
field = Fields(
|
||||
name=name,
|
||||
type=type,
|
||||
field_type=field_type,
|
||||
description=description,
|
||||
required=required,
|
||||
public=public,
|
||||
editable=editable,
|
||||
)
|
||||
db.session.add(field)
|
||||
db.session.commit()
|
||||
return field
|
||||
|
||||
|
||||
def simulate_user_activity(db, user):
|
||||
gen_tracking(db, user_id=user.id)
|
||||
gen_award(db, user_id=user.id)
|
||||
|
||||
204
tests/users/test_fields.py
Normal file
204
tests/users/test_fields.py
Normal file
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from CTFd.models import UserFieldEntries
|
||||
from tests.helpers import (
|
||||
create_ctfd,
|
||||
destroy_ctfd,
|
||||
gen_field,
|
||||
login_as_user,
|
||||
register_user,
|
||||
)
|
||||
|
||||
|
||||
def test_new_fields_show_on_pages():
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
register_user(app)
|
||||
|
||||
gen_field(app.db)
|
||||
|
||||
with login_as_user(app) as client:
|
||||
r = client.get("/register")
|
||||
assert "CustomField" in r.get_data(as_text=True)
|
||||
assert "CustomFieldDescription" in r.get_data(as_text=True)
|
||||
|
||||
r = client.get("/settings")
|
||||
assert "CustomField" in r.get_data(as_text=True)
|
||||
assert "CustomFieldDescription" in r.get_data(as_text=True)
|
||||
|
||||
r = client.patch(
|
||||
"/api/v1/users/me",
|
||||
json={"fields": [{"field_id": 1, "value": "CustomFieldEntry"}]},
|
||||
)
|
||||
resp = r.get_json()
|
||||
assert resp["success"] is True
|
||||
assert resp["data"]["fields"][0]["value"] == "CustomFieldEntry"
|
||||
assert resp["data"]["fields"][0]["description"] == "CustomFieldDescription"
|
||||
assert resp["data"]["fields"][0]["name"] == "CustomField"
|
||||
assert resp["data"]["fields"][0]["field_id"] == 1
|
||||
|
||||
r = client.get("/user")
|
||||
resp = r.get_data(as_text=True)
|
||||
assert "CustomField" in resp
|
||||
assert "CustomFieldEntry" in resp
|
||||
|
||||
r = client.get("/users/2")
|
||||
resp = r.get_data(as_text=True)
|
||||
assert "CustomField" in resp
|
||||
assert "CustomFieldEntry" in resp
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_fields_required_on_register():
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
gen_field(app.db)
|
||||
|
||||
with app.app_context():
|
||||
with app.test_client() as client:
|
||||
client.get("/register")
|
||||
with client.session_transaction() as sess:
|
||||
data = {
|
||||
"name": "user",
|
||||
"email": "user@ctfd.io",
|
||||
"password": "password",
|
||||
"nonce": sess.get("nonce"),
|
||||
}
|
||||
client.post("/register", data=data)
|
||||
with client.session_transaction() as sess:
|
||||
assert sess.get("id") is None
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
data = {
|
||||
"name": "user",
|
||||
"email": "user@ctfd.io",
|
||||
"password": "password",
|
||||
"fields[1]": "custom_field_value",
|
||||
"nonce": sess.get("nonce"),
|
||||
}
|
||||
client.post("/register", data=data)
|
||||
with client.session_transaction() as sess:
|
||||
assert sess["id"]
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_fields_properties():
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
register_user(app)
|
||||
|
||||
gen_field(
|
||||
app.db, name="CustomField1", required=True, public=True, editable=True
|
||||
)
|
||||
gen_field(
|
||||
app.db, name="CustomField2", required=False, public=True, editable=True
|
||||
)
|
||||
gen_field(
|
||||
app.db, name="CustomField3", required=False, public=False, editable=True
|
||||
)
|
||||
gen_field(
|
||||
app.db, name="CustomField4", required=False, public=False, editable=False
|
||||
)
|
||||
|
||||
with login_as_user(app) as client:
|
||||
r = client.get("/register")
|
||||
resp = r.get_data(as_text=True)
|
||||
assert "CustomField1" in resp
|
||||
assert "CustomField2" in resp
|
||||
assert "CustomField3" in resp
|
||||
assert "CustomField4" in resp
|
||||
|
||||
r = client.get("/settings")
|
||||
resp = r.get_data(as_text=True)
|
||||
assert "CustomField1" in resp
|
||||
assert "CustomField2" in resp
|
||||
assert "CustomField3" in resp
|
||||
assert "CustomField4" not in resp
|
||||
|
||||
r = client.patch(
|
||||
"/api/v1/users/me",
|
||||
json={
|
||||
"fields": [
|
||||
{"field_id": 1, "value": "CustomFieldEntry1"},
|
||||
{"field_id": 2, "value": "CustomFieldEntry2"},
|
||||
{"field_id": 3, "value": "CustomFieldEntry3"},
|
||||
{"field_id": 4, "value": "CustomFieldEntry4"},
|
||||
]
|
||||
},
|
||||
)
|
||||
resp = r.get_json()
|
||||
assert resp == {
|
||||
"success": False,
|
||||
"errors": {"fields": ["Field CustomField4 cannot be editted"]},
|
||||
}
|
||||
|
||||
r = client.patch(
|
||||
"/api/v1/users/me",
|
||||
json={
|
||||
"fields": [
|
||||
{"field_id": 1, "value": "CustomFieldEntry1"},
|
||||
{"field_id": 2, "value": "CustomFieldEntry2"},
|
||||
{"field_id": 3, "value": "CustomFieldEntry3"},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.get("/user")
|
||||
resp = r.get_data(as_text=True)
|
||||
assert "CustomField1" in resp
|
||||
assert "CustomField2" in resp
|
||||
assert "CustomField3" not in resp
|
||||
assert "CustomField4" not in resp
|
||||
|
||||
r = client.get("/users/2")
|
||||
resp = r.get_data(as_text=True)
|
||||
assert "CustomField1" in resp
|
||||
assert "CustomField2" in resp
|
||||
assert "CustomField3" not in resp
|
||||
assert "CustomField4" not in resp
|
||||
destroy_ctfd(app)
|
||||
|
||||
|
||||
def test_boolean_checkbox_field():
|
||||
app = create_ctfd()
|
||||
with app.app_context():
|
||||
gen_field(app.db, name="CustomField1", field_type="boolean", required=False)
|
||||
|
||||
with app.app_context():
|
||||
with app.test_client() as client:
|
||||
r = client.get("/register")
|
||||
resp = r.get_data(as_text=True)
|
||||
|
||||
# We should have rendered a checkbox input
|
||||
assert "checkbox" in resp
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
data = {
|
||||
"name": "user",
|
||||
"email": "user@ctfd.io",
|
||||
"password": "password",
|
||||
"nonce": sess.get("nonce"),
|
||||
"fields[1]": "y",
|
||||
}
|
||||
client.post("/register", data=data)
|
||||
with client.session_transaction() as sess:
|
||||
assert sess["id"]
|
||||
|
||||
assert UserFieldEntries.query.count() == 1
|
||||
assert UserFieldEntries.query.filter_by(id=1).first().value is True
|
||||
|
||||
with login_as_user(app) as client:
|
||||
r = client.get("/settings")
|
||||
resp = r.get_data(as_text=True)
|
||||
assert "CustomField1" in resp
|
||||
assert "checkbox" in resp
|
||||
|
||||
r = client.patch(
|
||||
"/api/v1/users/me", json={"fields": [{"field_id": 1, "value": False}]},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert UserFieldEntries.query.count() == 1
|
||||
assert UserFieldEntries.query.filter_by(id=1).first().value is False
|
||||
destroy_ctfd(app)
|
||||
Reference in New Issue
Block a user