Skip to content

Commit

Permalink
Challenge Topics (CTFd#1966)
Browse files Browse the repository at this point in the history
* Closes CTFd#1897 
* Adds Topics to Challenges where Topics are admin-only visible tags about challenges
* Adds `/api/v1/topics` and `/api/v1/challenges/[challenge_id]/topics` to API 
* Challenge comments have been moved into a modal
  • Loading branch information
ColdHeat authored Jul 30, 2021
1 parent 22a0c0b commit 27d862a
Show file tree
Hide file tree
Showing 18 changed files with 788 additions and 26 deletions.
2 changes: 2 additions & 0 deletions CTFd/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from CTFd.api.v1.tags import tags_namespace
from CTFd.api.v1.teams import teams_namespace
from CTFd.api.v1.tokens import tokens_namespace
from CTFd.api.v1.topics import topics_namespace
from CTFd.api.v1.unlocks import unlocks_namespace
from CTFd.api.v1.users import users_namespace

Expand All @@ -35,6 +36,7 @@

CTFd_API_v1.add_namespace(challenges_namespace, "/challenges")
CTFd_API_v1.add_namespace(tags_namespace, "/tags")
CTFd_API_v1.add_namespace(topics_namespace, "/topics")
CTFd_API_v1.add_namespace(awards_namespace, "/awards")
CTFd_API_v1.add_namespace(hints_namespace, "/hints")
CTFd_API_v1.add_namespace(flags_namespace, "/flags")
Expand Down
34 changes: 23 additions & 11 deletions CTFd/api/v1/challenges.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,9 @@
from CTFd.cache import clear_standings
from CTFd.constants import RawEnum
from CTFd.models import ChallengeFiles as ChallengeFilesModel
from CTFd.models import (
Challenges,
Fails,
Flags,
Hints,
HintUnlocks,
Solves,
Submissions,
Tags,
db,
)
from CTFd.models import Challenges
from CTFd.models import ChallengeTopics as ChallengeTopicsModel
from CTFd.models import Fails, Flags, Hints, HintUnlocks, Solves, Submissions, Tags, db
from CTFd.plugins.challenges import CHALLENGE_CLASSES, get_chal_class
from CTFd.schemas.challenges import ChallengeSchema
from CTFd.schemas.flags import FlagSchema
Expand Down Expand Up @@ -831,6 +823,26 @@ def get(self, challenge_id):
return {"success": True, "data": response}


@challenges_namespace.route("/<challenge_id>/topics")
class ChallengeTopics(Resource):
@admins_only
def get(self, challenge_id):
response = []

topics = ChallengeTopicsModel.query.filter_by(challenge_id=challenge_id).all()

for t in topics:
response.append(
{
"id": t.id,
"challenge_id": t.challenge_id,
"topic_id": t.topic_id,
"value": t.topic.value,
}
)
return {"success": True, "data": response}


@challenges_namespace.route("/<challenge_id>/hints")
class ChallengeHints(Resource):
@admins_only
Expand Down
177 changes: 177 additions & 0 deletions CTFd/api/v1/topics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
from typing import List

from flask import request
from flask_restx import Namespace, Resource

from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.constants import RawEnum
from CTFd.models import ChallengeTopics, Topics, db
from CTFd.schemas.topics import ChallengeTopicSchema, TopicSchema
from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters

topics_namespace = Namespace("topics", description="Endpoint to retrieve Topics")

TopicModel = sqlalchemy_to_pydantic(Topics)


class TopicDetailedSuccessResponse(APIDetailedSuccessResponse):
data: TopicModel


class TopicListSuccessResponse(APIListSuccessResponse):
data: List[TopicModel]


topics_namespace.schema_model(
"TopicDetailedSuccessResponse", TopicDetailedSuccessResponse.apidoc()
)

topics_namespace.schema_model(
"TopicListSuccessResponse", TopicListSuccessResponse.apidoc()
)


@topics_namespace.route("")
class TopicList(Resource):
@admins_only
@topics_namespace.doc(
description="Endpoint to list Topic objects in bulk",
responses={
200: ("Success", "TopicListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"value": (str, None),
"q": (str, None),
"field": (RawEnum("TopicFields", {"value": "value"}), 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=Topics, query=q, field=field)

topics = Topics.query.filter_by(**query_args).filter(*filters).all()
schema = TopicSchema(many=True)
response = schema.dump(topics)

if response.errors:
return {"success": False, "errors": response.errors}, 400

return {"success": True, "data": response.data}

@admins_only
@topics_namespace.doc(
description="Endpoint to create a Topic object",
responses={
200: ("Success", "TopicDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def post(self):
req = request.get_json()
value = req.get("value")

if value:
topic = Topics.query.filter_by(value=value).first()
if topic is None:
schema = TopicSchema()
response = schema.load(req, session=db.session)

if response.errors:
return {"success": False, "errors": response.errors}, 400

topic = response.data
db.session.add(topic)
db.session.commit()
else:
topic_id = req.get("topic_id")
topic = Topics.query.filter_by(id=topic_id).first_or_404()

req["topic_id"] = topic.id
topic_type = req.get("type")
if topic_type == "challenge":
schema = ChallengeTopicSchema()
response = schema.load(req, session=db.session)
else:
return {"success": False}, 400

db.session.add(response.data)
db.session.commit()

response = schema.dump(response.data)
db.session.close()

return {"success": True, "data": response.data}

@admins_only
@topics_namespace.doc(
description="Endpoint to delete a specific Topic object of a specific type",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
@validate_args(
{"type": (str, None), "target_id": (int, 0)}, location="query",
)
def delete(self, query_args):
topic_type = query_args.get("type")
target_id = int(query_args.get("target_id", 0))

if topic_type == "challenge":
Model = ChallengeTopics
else:
return {"success": False}, 400

topic = Model.query.filter_by(id=target_id).first_or_404()
db.session.delete(topic)
db.session.commit()
db.session.close()

return {"success": True}


@topics_namespace.route("/<topic_id>")
class Topic(Resource):
@admins_only
@topics_namespace.doc(
description="Endpoint to get a specific Topic object",
responses={
200: ("Success", "TopicDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def get(self, topic_id):
topic = Topics.query.filter_by(id=topic_id).first_or_404()
response = TopicSchema().dump(topic)

if response.errors:
return {"success": False, "errors": response.errors}, 400

return {"success": True, "data": response.data}

@admins_only
@topics_namespace.doc(
description="Endpoint to delete a specific Topic object",
responses={200: ("Success", "APISimpleSuccessResponse")},
)
def delete(self, topic_id):
topic = Topics.query.filter_by(id=topic_id).first_or_404()
db.session.delete(topic)
db.session.commit()
db.session.close()

return {"success": True}
26 changes: 26 additions & 0 deletions CTFd/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ class Challenges(db.Model):
hints = db.relationship("Hints", backref="challenge")
flags = db.relationship("Flags", backref="challenge")
comments = db.relationship("ChallengeComments", backref="challenge")
topics = db.relationship("ChallengeTopics", backref="challenge")

class alt_defaultdict(defaultdict):
"""
Expand Down Expand Up @@ -222,6 +223,31 @@ def __init__(self, *args, **kwargs):
super(Tags, self).__init__(**kwargs)


class Topics(db.Model):
__tablename__ = "topics"
id = db.Column(db.Integer, primary_key=True)
value = db.Column(db.String(255), unique=True)

def __init__(self, *args, **kwargs):
super(Topics, self).__init__(**kwargs)


class ChallengeTopics(db.Model):
__tablename__ = "challenge_topics"
id = db.Column(db.Integer, primary_key=True)
challenge_id = db.Column(
db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE")
)
topic_id = db.Column(db.Integer, db.ForeignKey("topics.id", ondelete="CASCADE"))

topic = db.relationship(
"Topics", foreign_keys="ChallengeTopics.topic_id", lazy="select"
)

def __init__(self, *args, **kwargs):
super(ChallengeTopics, self).__init__(**kwargs)


class Files(db.Model):
__tablename__ = "files"
id = db.Column(db.Integer, primary_key=True)
Expand Down
38 changes: 38 additions & 0 deletions CTFd/schemas/topics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from CTFd.models import ChallengeTopics, Topics, ma
from CTFd.utils import string_types


class TopicSchema(ma.ModelSchema):
class Meta:
model = Topics
include_fk = True
dump_only = ("id",)

views = {"admin": ["id", "value"]}

def __init__(self, view=None, *args, **kwargs):
if view:
if isinstance(view, string_types):
kwargs["only"] = self.views[view]
elif isinstance(view, list):
kwargs["only"] = view

super(TopicSchema, self).__init__(*args, **kwargs)


class ChallengeTopicSchema(ma.ModelSchema):
class Meta:
model = ChallengeTopics
include_fk = True
dump_only = ("id",)

views = {"admin": ["id", "challenge_id", "topic_id"]}

def __init__(self, view=None, *args, **kwargs):
if view:
if isinstance(view, string_types):
kwargs["only"] = self.views[view]
elif isinstance(view, list):
kwargs["only"] = view

super(ChallengeTopicSchema, self).__init__(*args, **kwargs)
Loading

0 comments on commit 27d862a

Please sign in to comment.