Add API searching and filtering (#1515)

* Works on #1318 
* Adds searching and filtering to most of the bulk API endpoints
* Adds documentation on the GET parameters used to conduct searches
This commit is contained in:
Kevin Chung
2020-06-28 14:08:54 -04:00
committed by GitHub
parent a769e2c91f
commit efb9831d2a
14 changed files with 394 additions and 44 deletions

View File

@@ -3,9 +3,12 @@ from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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.cache import clear_standings
from CTFd.constants import RawEnum
from CTFd.models import Awards, Users, db
from CTFd.schemas.awards import AwardSchema
from CTFd.utils.config import is_teams_mode
@@ -35,6 +38,55 @@ awards_namespace.schema_model(
@awards_namespace.route("")
class AwardList(Resource):
@admins_only
@awards_namespace.doc(
description="Endpoint to list Award objects in bulk",
responses={
200: ("Success", "AwardListSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
@validate_args(
{
"user_id": (int, None),
"team_id": (int, None),
"type": (str, None),
"value": (int, None),
"category": (int, None),
"icon": (int, None),
"q": (str, None),
"field": (
RawEnum(
"AwardFields",
{
"name": "name",
"description": "description",
"category": "category",
"icon": "icon",
},
),
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=Awards, query=q, field=field)
awards = Awards.query.filter_by(**query_args).filter(*filters).all()
schema = AwardSchema(many=True)
response = schema.dump(awards)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
@awards_namespace.doc(
description="Endpoint to create an Award object",

View File

@@ -5,9 +5,12 @@ from flask import abort, render_template, request, url_for
from flask_restx import Namespace, Resource
from sqlalchemy.sql import and_
from CTFd.api.v1.helpers.models import build_model_filters
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.cache import clear_standings
from CTFd.constants import RawEnum
from CTFd.models import ChallengeFiles as ChallengeFilesModel
from CTFd.models import (
Challenges,
@@ -86,19 +89,56 @@ class ChallengeList(Resource):
),
},
)
def get(self):
@validate_args(
{
"name": (str, None),
"max_attempts": (int, None),
"value": (int, None),
"category": (str, None),
"type": (str, None),
"state": (str, None),
"q": (str, None),
"field": (
RawEnum(
"ChallengeFields",
{
"name": "name",
"description": "description",
"category": "category",
"type": "type",
"state": "state",
},
),
None,
),
},
location="query",
)
def get(self, query_args):
# Build filtering queries
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Challenges, query=q, field=field)
# This can return None (unauth) if visibility is set to public
user = get_current_user()
# Admins can request to see everything
if is_admin() and request.args.get("view") == "admin":
challenges = Challenges.query.order_by(Challenges.value).all()
challenges = (
Challenges.query.filter_by(**query_args)
.filter(*filters)
.order_by(Challenges.value)
.all()
)
solve_ids = set([challenge.id for challenge in challenges])
else:
challenges = (
Challenges.query.filter(
and_(Challenges.state != "hidden", Challenges.state != "locked")
)
.filter_by(**query_args)
.filter(*filters)
.order_by(Challenges.value)
.all()
)

View File

@@ -3,9 +3,12 @@ from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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.cache import clear_config, clear_standings
from CTFd.constants import RawEnum
from CTFd.models import Configs, db
from CTFd.schemas.config import ConfigSchema
from CTFd.utils import get_config, set_config
@@ -46,8 +49,21 @@ class ConfigList(Resource):
),
},
)
def get(self):
configs = Configs.query.all()
@validate_args(
{
"key": (str, None),
"value": (str, None),
"q": (str, None),
"field": (RawEnum("ConfigFields", {"key": "key", "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=Configs, query=q, field=field)
configs = Configs.query.filter_by(**query_args).filter(*filters).all()
schema = ConfigSchema(many=True)
response = schema.dump(configs)
if response.errors:

View File

@@ -3,8 +3,11 @@ from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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 Files, db
from CTFd.schemas.files import FileSchema
from CTFd.utils import uploads
@@ -45,9 +48,24 @@ class FilesList(Resource):
),
},
)
def get(self):
file_type = request.args.get("type")
files = Files.query.filter_by(type=file_type).all()
@validate_args(
{
"type": (str, None),
"location": (str, None),
"q": (str, None),
"field": (
RawEnum("FileFields", {"type": "type", "location": "location"}),
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=Files, query=q, field=field)
files = Files.query.filter_by(**query_args).filter(*filters).all()
schema = FileSchema(many=True)
response = schema.dump(files)

View File

@@ -3,8 +3,11 @@ from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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 Flags, db
from CTFd.plugins.flags import FLAG_CLASSES, get_flag_class
from CTFd.schemas.flags import FlagSchema
@@ -45,8 +48,28 @@ class FlagList(Resource):
),
},
)
def get(self):
flags = Flags.query.all()
@validate_args(
{
"challenge_id": (int, None),
"type": (str, None),
"content": (str, None),
"data": (str, None),
"q": (str, None),
"field": (
RawEnum(
"FlagFields", {"type": "type", "content": "content", "data": "data"}
),
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=Flags, query=q, field=field)
flags = Flags.query.filter_by(**query_args).filter(*filters).all()
schema = FlagSchema(many=True)
response = schema.dump(flags)
if response.errors:

View File

@@ -0,0 +1,7 @@
def build_model_filters(model, query, field):
filters = []
if query:
# The field exists as an exposed column
if model.__mapper__.has_property(field):
filters.append(getattr(model, field).like("%{}%".format(query)))
return filters

View File

@@ -3,8 +3,11 @@ from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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 Hints, HintUnlocks, db
from CTFd.schemas.hints import HintSchema
from CTFd.utils.decorators import admins_only, authed_only, during_ctf_time_only
@@ -45,8 +48,26 @@ class HintList(Resource):
),
},
)
def get(self):
hints = Hints.query.all()
@validate_args(
{
"type": (str, None),
"challenge_id": (int, None),
"content": (str, None),
"cost": (int, None),
"q": (str, None),
"field": (
RawEnum("HintFields", {"type": "type", "content": "content"}),
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=Hints, query=q, field=field)
hints = Hints.query.filter_by(**query_args).filter(*filters).all()
response = HintSchema(many=True).dump(hints)
if response.errors:

View File

@@ -3,8 +3,11 @@ from typing import List
from flask import current_app, request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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 Notifications, db
from CTFd.schemas.notifications import NotificationSchema
from CTFd.utils.decorators import admins_only
@@ -46,8 +49,28 @@ class NotificantionList(Resource):
),
},
)
def get(self):
notifications = Notifications.query.all()
@validate_args(
{
"title": (str, None),
"content": (str, None),
"user_id": (int, None),
"team_id": (int, None),
"q": (str, None),
"field": (
RawEnum("NotificationFields", {"title": "title", "content": "content"}),
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=Notifications, query=q, field=field)
notifications = (
Notifications.query.filter_by(**query_args).filter(*filters).all()
)
schema = NotificationSchema(many=True)
result = schema.dump(notifications)
if result.errors:

View File

@@ -3,10 +3,12 @@ from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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.cache import clear_pages
from CTFd.constants import RawEnum
from CTFd.models import Pages, db
from CTFd.schemas.pages import PageSchema
from CTFd.utils.decorators import admins_only
@@ -59,11 +61,23 @@ class PageList(Resource):
"draft": (bool, None),
"hidden": (bool, None),
"auth_required": (bool, None),
"q": (str, None),
"field": (
RawEnum(
"PageFields",
{"title": "title", "route": "route", "content": "content"},
),
None,
),
},
location="query",
)
def get(self, query):
pages = Pages.query.filter_by(**query).all()
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Pages, query=q, field=field)
pages = Pages.query.filter_by(**query_args).filter(*filters).all()
schema = PageSchema(exclude=["content"], many=True)
response = schema.dump(pages)
if response.errors:

View File

@@ -1,8 +1,8 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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 (
@@ -10,6 +10,7 @@ from CTFd.api.v1.schemas import (
PaginatedAPIListSuccessResponse,
)
from CTFd.cache import clear_standings
from CTFd.constants import RawEnum
from CTFd.models import Submissions, db
from CTFd.schemas.submissions import SubmissionSchema
from CTFd.utils.decorators import admins_only
@@ -52,21 +53,45 @@ class SubmissionsList(Resource):
),
},
)
def get(self):
args = request.args.to_dict()
@validate_args(
{
"challenge_id": (int, None),
"user_id": (int, None),
"team_id": (int, None),
"ip": (str, None),
"provided": (str, None),
"type": (str, None),
"q": (str, None),
"field": (
RawEnum(
"SubmissionFields",
{
"challenge_id": "challenge_id",
"user_id": "user_id",
"team_id": "team_id",
"ip": "ip",
"provided": "provided",
"type": "type",
},
),
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=Submissions, query=q, field=field)
args = query_args
schema = SubmissionSchema(many=True)
pagination_args = {
"per_page": int(args.pop("per_page", 50)),
"page": int(args.pop("page", 1)),
}
if args:
submissions = Submissions.query.filter_by(**args).paginate(
**pagination_args, max_per_page=100
)
else:
submissions = Submissions.query.paginate(
**pagination_args, max_per_page=100
)
submissions = (
Submissions.query.filter_by(**args)
.filter(*filters)
.paginate(max_per_page=100)
)
response = schema.dump(submissions.items)

View File

@@ -3,8 +3,11 @@ from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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 Tags, db
from CTFd.schemas.tags import TagSchema
from CTFd.utils.decorators import admins_only
@@ -42,9 +45,26 @@ class TagList(Resource):
),
},
)
def get(self):
# TODO: Filter by challenge_id
tags = Tags.query.all()
@validate_args(
{
"challenge_id": (int, None),
"value": (str, None),
"q": (str, None),
"field": (
RawEnum(
"TagFields", {"challenge_id": "challenge_id", "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=Tags, query=q, field=field)
tags = Tags.query.filter_by(**query_args).filter(*filters).all()
schema = TagSchema(many=True)
response = schema.dump(tags)

View File

@@ -4,12 +4,15 @@ from typing import List
from flask import abort, request, session
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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,
PaginatedAPIListSuccessResponse,
)
from CTFd.cache import clear_standings, clear_team_session, clear_user_session
from CTFd.constants import RawEnum
from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db
from CTFd.schemas.awards import AwardSchema
from CTFd.schemas.submissions import SubmissionSchema
@@ -57,12 +60,44 @@ class TeamList(Resource):
),
},
)
def get(self):
@validate_args(
{
"affiliation": (str, None),
"country": (str, None),
"bracket": (str, None),
"q": (str, None),
"field": (
RawEnum(
"TeamFields",
{
"name": "name",
"website": "website",
"country": "country",
"bracket": "bracket",
"affiliation": "affiliation",
},
),
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=Teams, query=q, field=field)
if is_admin() and request.args.get("view") == "admin":
teams = Teams.query.filter_by().paginate(per_page=50, max_per_page=100)
teams = (
Teams.query.filter_by(**query_args)
.filter(*filters)
.paginate(per_page=50, max_per_page=100)
)
else:
teams = Teams.query.filter_by(hidden=False, banned=False).paginate(
per_page=50, max_per_page=100
teams = (
Teams.query.filter_by(hidden=False, banned=False, **query_args)
.filter(*filters)
.paginate(per_page=50, max_per_page=100)
)
user_type = get_current_user_type(fallback="user")

View File

@@ -3,9 +3,12 @@ from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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.cache import clear_standings
from CTFd.constants import RawEnum
from CTFd.models import Unlocks, db, get_class_by_tablename
from CTFd.schemas.awards import AwardSchema
from CTFd.schemas.unlocks import UnlockSchema
@@ -53,10 +56,28 @@ class UnlockList(Resource):
),
},
)
def get(self):
hints = Unlocks.query.all()
@validate_args(
{
"user_id": (int, None),
"team_id": (int, None),
"target": (int, None),
"type": (str, None),
"q": (str, None),
"field": (
RawEnum("UnlockFields", {"target": "target", "type": "type"}),
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=Unlocks, query=q, field=field)
unlocks = Unlocks.query.filter_by(**query_args).filter(*filters).all()
schema = UnlockSchema()
response = schema.dump(hints)
response = schema.dump(unlocks)
if response.errors:
return {"success": False, "errors": response.errors}, 400

View File

@@ -3,12 +3,15 @@ from typing import List
from flask import abort, request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.models import build_model_filters
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,
PaginatedAPIListSuccessResponse,
)
from CTFd.cache import clear_standings, clear_user_session
from CTFd.constants import RawEnum
from CTFd.models import (
Awards,
Notifications,
@@ -69,12 +72,44 @@ class UserList(Resource):
),
},
)
def get(self):
@validate_args(
{
"affiliation": (str, None),
"country": (str, None),
"bracket": (str, None),
"q": (str, None),
"field": (
RawEnum(
"UserFields",
{
"name": "name",
"website": "website",
"country": "country",
"bracket": "bracket",
"affiliation": "affiliation",
},
),
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=Users, query=q, field=field)
if is_admin() and request.args.get("view") == "admin":
users = Users.query.filter_by().paginate(per_page=50, max_per_page=100)
users = (
Users.query.filter_by(**query_args)
.filter(*filters)
.paginate(per_page=50, max_per_page=100)
)
else:
users = Users.query.filter_by(banned=False, hidden=False).paginate(
per_page=50, max_per_page=100
users = (
Users.query.filter_by(banned=False, hidden=False, **query_args)
.filter(*filters)
.paginate(per_page=50, max_per_page=100)
)
response = UserSchema(view="user", many=True).dump(users.items)