class RegisterResource(Resource): @use_args({ 'username': fields.Str(required=True, location='form'), 'password': fields.Str(required=True, location='form', validate=validate.Length(min=1)), 'format': fields.Str(missing='json', location='query', validate=validate.Equal('json')) }) def post(self, args): user = User(username=args['username'], password=args['password']) db.session.add(user) try: db.session.commit() except IntegrityError: db.session.rollback() return custom_error('username', ['Username already in use']), ErrorCode.BAD_REQUEST return {'message': 'OK'}
class LoginResource(Resource): @use_args({ 'username': fields.Str(required=True, location='form'), 'password': fields.Str(required=True, location='form'), 'format': fields.Str(missing='json', location='query', validate=validate.Equal('json')) }) def post(self, args): user = User.query.filter(User.username == args['username']).first() if not user: return custom_error('username', ['Invalid username']), ErrorCode.BAD_REQUEST elif not user.verify_password(args['password']): return custom_error('password', ['Wrong password']), ErrorCode.BAD_REQUEST _login(user) return {'token': user.token}
class StatsResource(Resource): @requires_auth @use_args({ 'dateFrom': fields.Date(required=True, location='query'), 'dateTo': fields.Date(required=True, location='query'), 'product': fields.Int(required=True, location='query'), 'format': fields.Str(missing='json', location='query', validate=validate.Equal('json')) }) def get(self, args, **kwargs): query = db.session.query(Price.date, func.min(Price.price), func.avg(Price.price), func.max(Price.price)).\ filter(Price.product_id == args['product'], Price.date.between(args['dateFrom'], args['dateTo'])).\ group_by(Price.date).order_by(Price.date.asc()) stats = [Stat(*x) for x in query.all()] return StatSchema(many=True).dump(stats).data
class ShopsResource(Resource): @use_args({ 'start': fields.Int(missing=0, location='query', validate=validate.Range(min=0)), 'count': fields.Int(missing=20, location='query', validate=validate.Range(min=0)), 'sort': fields.List( fields.Str(validate=validate.OneOf(SORT_CHOICE)), missing=['id|DESC'], location='query' # default sort order not explicitly specified, maybe same as products? ), 'status': fields.Str(missing='ACTIVE', location='query', validate=validate.OneOf(STATUS_CHOICE)), 'format': fields.Str(missing='json', location='query', validate=validate.Equal('json')) }) def get(self, args): query = Shop.query start = args['start'] count = args['count'] status = args['status'] sorts = [x.split('|') for x in args['sort']] def to_sort_operator(field, order): sort_field = {'id': Shop.id, 'name': Shop.name}[field] sort_order = {'ASC': asc, 'DESC': desc}[order] return sort_order(sort_field) if status != 'ALL': query = query.filter_by(withdrawn=(status == 'WITHDRAWN')) query = query.order_by( *[to_sort_operator(field, order) for field, order in sorts]) total = query.count() query = query.offset(start).limit(count) shops_page = query.all() shops = shop_schema.dump(shops_page, many=True).data return {'start': start, 'count': count, 'total': total, 'shops': shops} @requires_auth @use_args({ 'name': fields.Str(required=True, location='form'), 'address': fields.Str(required=True, location='form'), 'lng': fields.Float(required=True, location='form'), 'lat': fields.Float(required=True, location='form'), 'tags': fields.List(fields.String(), required=True, location='form'), 'format': fields.Str(missing='json', location='query', validate=validate.Equal('json')) }) def post(self, args, **_kwargs): position = from_shape(Point(args['lng'], args['lat']), srid=4326) new_shop = Shop(name=args['name'], address=args['address'], position=position, withdrawn=False) new_shop.tags = [ ShopTag(name=tag, shop=new_shop) for tag in unique_stripped(args['tags']) ] db.session.add(new_shop) try: db.session.commit() except IntegrityError as e: db.session.rollback() if "shop_pna_c" in e.orig: return custom_error('Address/Position/Name', ['Same address, position and name with existing shop']), \ ErrorCode.BAD_REQUEST else: return custom_error( 'tags', ['Duplicate tags' ]), ErrorCode.BAD_REQUEST # we should never get here return shop_schema.dump(new_shop).data
class ShopResource(Resource): @use_args({ 'format': fields.Str(location='query', validate=validate.Equal('json')) }) def get(self, _args, shop_id): shop = Shop.query.get_or_404(shop_id) return shop_schema.dump(shop).data @requires_auth @use_args({ 'name': fields.Str(required=True, location='form'), 'address': fields.Str(required=True, location='form'), 'lng': fields.Float(required=True, location='form'), 'lat': fields.Float(required=True, location='form'), 'tags': fields.List(fields.Str(), required=True, location='form'), 'format': fields.Str(location='query', validate=validate.Equal('json')) }) def put(self, args, shop_id, **_kwargs): shop = Shop.query.get_or_404(shop_id) shop.name = args['name'] shop.address = args['address'] shop.position = from_shape(Point(args['lng'], args['lat']), srid=4326) for tag in shop.tags: db.session.delete(tag) try: db.session.flush() except IntegrityError: db.session.rollback() return custom_error('Address/Position/Name', ['Same address, position and name with existing shop']), \ ErrorCode.BAD_REQUEST # flush delete's to db to ensure delete stmts precede insert stmt and avoid integrity error shop.tags = [ ShopTag(name=tag, shop=shop) for tag in unique_stripped(args['tags']) ] try: db.session.commit() except IntegrityError: db.session.rollback() return custom_error( 'tags', ['Duplicate tags' ]), ErrorCode.BAD_REQUEST # we should never get here return shop_schema.dump(shop).data @requires_auth @use_args({ 'name': fields.Str(location='form'), 'address': fields.Str(location='form'), 'lng': fields.Float(location='form'), 'lat': fields.Float(location='form'), 'tags': fields.List(fields.Str(), location='form'), 'format': fields.Str(missing='json', location='query', validate=validate.Equal('json')) }) def patch(self, args, shop_id, **_kwargs): del args['format'] if len(args) != 1: return custom_error('patch', ['Specify exactly one of: name, address, lng, lat, tags']), \ ErrorCode.BAD_REQUEST shop = Shop.query.get_or_404(shop_id) changed = next(iter(args.keys())) if changed == 'tags': for tag in shop.tags: db.session.delete(tag) db.session.flush() shop.tags = [ ShopTag(name=tag, shop=shop) for tag in unique_stripped(args['tags']) ] elif changed == 'lat': old = to_shape(shop.position) shop.position = from_shape(Point(old.x, args['lat']), srid=4326) elif changed == 'lng': old = to_shape(shop.position) shop.position = from_shape(Point(args['lng'], old.y), srid=4326) else: setattr(shop, changed, args[changed]) try: db.session.commit() except IntegrityError: db.session.rollback() if changed == 'tags': return custom_error( 'tags', ['Duplicate tags' ]), ErrorCode.BAD_REQUEST # we should never get here else: return custom_error('Address/Position/Name', ['Same address, position and name with existing shop']), \ ErrorCode.BAD_REQUEST return ShopSchema().dump(shop).data @requires_auth @use_args({ 'format': fields.Str(missing='json', location='query', validate=validate.Equal('json')) }) def delete(self, _args, shop_id, is_admin, **_kwargs): shop = Shop.query.get(shop_id) if not shop: return custom_error('shop', ['Invalid shop id']), ErrorCode.NOT_FOUND if is_admin: db.session.delete(shop) else: shop.withdrawn = True db.session.commit() return {'message': 'OK'}
class ProductsResource(Resource): _STATUS_CHOICE = ['ALL', 'WITHDRAWN', 'ACTIVE'] _SORT_CHOICE = ['id|ASC', 'id|DESC', 'name|ASC', 'name|DESC'] @use_args({ 'start': fields.Int(missing=0, location='query', validate=validate.Range(min=0)), 'count': fields.Int(missing=20, location='query', validate=validate.Range(min=0)), 'status': fields.Str(missing='ACTIVE', location='query', validate=validate.OneOf(_STATUS_CHOICE)), 'sort': fields.List(fields.Str(validate=validate.OneOf(_SORT_CHOICE)), missing=['id|DESC'], location='query'), 'format': fields.Str(missing='json', location='query', validate=validate.Equal('json')) }) def get(self, args, **_kwargs): start = args['start'] count = args['count'] status = args['status'] sorts = [x.split('|') for x in args['sort']] def to_sort_operator(field, order): sort_field = {'id': Product.id, 'name': Product.name}[field] sort_order = {'ASC': asc, 'DESC': desc}[order] return sort_order(sort_field) query = Product.query if status != 'ALL': query = query.filter_by(withdrawn=(status == 'WITHDRAWN')) total = query.count() query = query.order_by( *[to_sort_operator(field, order) for field, order in sorts]).offset(start).limit(count) products = query.all() return { 'start': start, 'count': count, 'total': total, 'products': prod_schema.dump(products, many=True).data } @requires_auth @use_args({ 'name': fields.Str(location='form', required=True), 'description': fields.Str(location='form', required=True), 'category': fields.Str(location='form', required=True), 'tags': fields.List(fields.Str(), location='form', required=True), 'format': fields.Str(missing='json', location='query', validate=validate.Equal('json')) }) def post(self, args, **_kwargs): product = Product(name=args['name'], description=args['description'], category=args['category'], withdrawn=False) product.tags = [ ProductTag(name=tag, product=product) for tag in unique_stripped(args['tags']) ] db.session.add(product) try: db.session.commit() except IntegrityError: db.session.rollback() return custom_error( 'tags', ['Duplicate tags' ]), ErrorCode.BAD_REQUEST # we should never get here return prod_schema.dump(product).data
class ProductResource(Resource): @use_args({ 'format': fields.Str(missing='json', location='query', validate=validate.Equal('json')) }) def get(self, _args, prod_id): product = Product.query.get_or_404(prod_id) return prod_schema.dump(product).data @requires_auth @use_args({ 'name': fields.Str(location='form', required=True), 'description': fields.Str(location='form', required=True), 'category': fields.Str(location='form', required=True), 'tags': fields.List(fields.Str(), location='form', required=True), 'format': fields.Str(missing='json', location='query', validate=validate.Equal('json')) }) def put(self, args, prod_id, **_kwargs): product = Product.query.get_or_404(prod_id) product.name = args['name'] product.description = args['description'] product.category = args['category'] for tag in product.tags: db.session.delete(tag) # delete old product tags db.session.flush() # flush delete's to db to ensure delete stmts precedes insert stmt and avoid integrity error product.tags = [ ProductTag(name=tag, product=product) for tag in unique_stripped(args['tags']) ] try: db.session.commit() except IntegrityError: db.session.rollback() return custom_error( 'tags', ['Duplicate tags' ]), ErrorCode.BAD_REQUEST # we should never get here return prod_schema.dump(product).data @requires_auth @use_args({ 'name': fields.Str(location='form'), 'description': fields.Str(location='form'), 'category': fields.Str(location='form'), 'tags': fields.List(fields.Str(), location='form'), 'format': fields.Str(missing='json', location='query', validate=validate.Equal('json')) }) def patch(self, args, prod_id, **_kwargs): del args['format'] if len(args) != 1: return custom_error('patch', ['Specify exactly one of: name, description, category or tags']),\ ErrorCode.BAD_REQUEST product = Product.query.get_or_404(prod_id) changed = next(iter(args.keys())) if changed == 'tags': for tag in product.tags: db.session.delete(tag) # delete old product tags db.session.flush() product.tags = [ ProductTag(name=tag, product=product) for tag in unique_stripped(args['tags']) ] else: setattr(product, changed, args[changed]) try: db.session.commit() except IntegrityError: db.session.rollback() custom_error('tags', ['Duplicate tags' ]), ErrorCode.BAD_REQUEST # we should never get here return prod_schema.dump(product).data @requires_auth @use_args({ 'format': fields.Str(missing='json', location='query', validate=validate.Equal('json')) }) def delete(self, _args, prod_id, is_admin, **_kwargs): product = Product.query.get_or_404(prod_id) if is_admin: db.session.delete(product) else: product.withdrawn = True db.session.commit() return {'message': 'OK'}
class Mods(RouteCog): @staticmethod def dict_all(models): return [m.to_dict() for m in models] @multiroute("/api/v1/mods", methods=["GET"], other_methods=["POST"]) @json @use_kwargs( { "q": fields.Str(), "page": fields.Int(missing=0), "limit": fields.Int(missing=50), "category": EnumField(ModCategory), "rating": fields.Int(validate=validate.OneOf([1, 2, 3, 4, 5])), "status": EnumField(ModStatus), "sort": EnumField(ModSorting), "ascending": fields.Bool(missing=False), }, locations=("query", ), ) async def get_mods( self, q: str = None, page: int = None, limit: int = None, category: ModCategory = None, rating: int = None, status: ModStatus = None, sort: ModSorting = None, ascending: bool = None, ): if not 1 <= limit <= 100: limit = max(1, min( limit, 100)) # Clamp `limit` to 1 or 100, whichever is appropriate page = page - 1 if page > 0 else 0 query = Mod.query.where(Mod.verified) if q is not None: like = f"%{q}%" query = query.where( and_( Mod.title.match(q), Mod.tagline.match(q), Mod.description.match(q), Mod.title.ilike(like), Mod.tagline.ilike(like), Mod.description.ilike(like), )) if category is not None: query = query.where(Mod.status == category) if rating is not None: query = query.where(rating + 1 > db.select([ func.avg( Review.select("rating").where(Review.mod_id == Mod.id)) ]) >= rating) if status is not None: query = query.where(Mod.status == status) if sort is not None: sort_by = mod_sorters[sort] query = query.order_by( sort_by.asc() if ascending else sort_by.desc()) results = await paginate(query, page, limit).gino.all() total = await query.alias().count().gino.scalar() return jsonify(total=total, page=page, limit=limit, results=self.dict_all(results)) @multiroute("/api/v1/mods", methods=["POST"], other_methods=["GET"]) @requires_login @json @use_kwargs( { "title": fields.Str(required=True, validate=validate.Length(max=64)), "tagline": fields.Str(required=True, validate=validate.Length(max=100)), "description": fields.Str(required=True, validate=validate.Length(min=100, max=10000)), "website": fields.Url(required=True), "status": EnumField(ModStatus, required=True), "category": EnumField(ModCategory, required=True), "authors": fields.List(fields.Nested(AuthorSchema), required=True), "icon": b64img_field("icon"), "banner": b64img_field("banner"), "media": fields.List(b64img_field("media"), required=True), "is_private_beta": fields.Bool(missing=False), "mod_playtester": fields.List(fields.Str()), "color": EnumField(ModColor, missing=ModColor.default), "recaptcha": fields.Str(required=True), }, locations=("json", ), ) async def post_mods( self, title: str, tagline: str, description: str, website: str, authors: List[dict], status: ModStatus, category: ModCategory, icon: str, banner: str, media: List[str], recaptcha: str, color: ModColor, is_private_beta: bool = None, mod_playtester: List[str] = None, ): score = await verify_recaptcha(recaptcha, self.core.aioh_sess, "create_mod") if score < 0.5: # TODO: discuss what to do here abort(400, "Possibly a bot") user_id = await get_token_user() # Check if any mod with a similar enough name exists already. generalized_title = generalize_text(title) mods = await Mod.get_any(True, generalized_title=generalized_title).first() if mods is not None: abort(400, "A mod with that title already exists") if status is ModStatus.archived: abort(400, "Can't create a new archived mod") mod = Mod( title=title, tagline=tagline, description=description, website=website, status=status, category=category, theme_color=color, ) icon_mimetype, icon_data = validate_img(icon, "icon") banner_mimetype, banner_data = validate_img(banner, "banner") media = [validate_img(x, "media") for x in media] for i, author in enumerate(authors): if author["id"] == user_id: authors.pop(i) continue elif not await User.exists(author["id"]): abort(400, f"Unknown user '{author['id']}'") authors.append({"id": user_id, "role": AuthorRole.owner}) if is_private_beta is not None: mod.is_private_beta = is_private_beta if mod_playtester is not None: if not is_private_beta: abort(400, "No need for `ModPlaytester` if open beta") for playtester in mod_playtester: if not await User.exists(playtester): abort(400, f"Unknown user '{playtester}'") # Decode images and add name for mimetypes icon_data = base64.b64decode(icon_data) banner_data = base64.b64decode(banner_data) icon_ext = icon_mimetype.split("/")[1] banner_ext = banner_mimetype.split("/")[1] icon_resp = await ipfs_upload(icon_data, f"icon.{icon_ext}", self.core.aioh_sess) banner_resp = await ipfs_upload(banner_data, f"banner.{banner_ext}", self.core.aioh_sess) mod.icon = icon_resp["Hash"] mod.banner = banner_resp["Hash"] media_hashes = [] for mime, data in media: ext = mime.split("/")[1] resp = await ipfs_upload(data, f"media.{ext}", self.core.aioh_sess) media_hashes += [resp] await mod.create() await ModAuthor.insert().gino.all(*[ dict(user_id=author["id"], mod_id=mod.id, role=author["role"]) for author in authors ]) await ModPlaytester.insert().gino.all( *[dict(user_id=user, mod_id=mod.id) for user in mod_playtester]) if media_hashes: await Media.insert().gino.all(*[ dict(type=MediaType.image, hash=hash_, mod_id=mod.id) for hash_ in media_hashes ]) return jsonify(mod.to_dict()) @route("/api/v1/mods/recent_releases") @json async def get_recent_releases(self): mods = (await Mod.query.where( and_(Mod.verified, Mod.status == ModStatus.released) ).order_by(Mod.released_at.desc()).limit(10).gino.all()) return jsonify(self.dict_all(mods)) @route("/api/v1/mods/most_loved") @json async def get_most_loved(self): love_counts = (select( [func.count()]).where(UserFavorite.mod_id == Mod.id).as_scalar()) mods = await Mod.query.order_by(love_counts.desc() ).limit(10).gino.all() return jsonify(self.dict_all(mods)) @route("/api/v1/mods/most_downloads") @json async def get_most_downloads(self): mods = (await Mod.query.where(and_(Mod.verified, Mod.released_at is not None) ).order_by(Mod.downloads.desc() ).limit(10).gino.all()) return jsonify(self.dict_all(mods)) @route("/api/v1/mods/trending") @json async def get_trending(self): # TODO: implement return jsonify([]) @route("/api/v1/mods/editors_choice") @json async def get_ec(self): mod_ids = [x.mod_id for x in await EditorsChoice.query.gino.all()] mods = (await Mod.query.where(Mod.id in mod_ids ).order_by(Mod.downloads.desc() ).limit(10).gino.all()) return jsonify(mods) @multiroute("/api/v1/mods/<mod_id>", methods=["GET"], other_methods=["PATCH", "DELETE"]) @json async def get_mod(self, mod_id: str): # mod = await Mod.get(mod_id) mod = (await Mod.load(authors=ModAuthor, media=Media).where(Mod.id == mod_id).gino.first()) if mod is None: abort(404, "Unknown mod") return jsonify(mod.to_dict()) @multiroute("/api/v1/mods/<mod_id>", methods=["PATCH"], other_methods=["GET", "DELETE"]) @requires_login @json @use_kwargs( { "title": fields.Str(validate=validate.Length(max=64)), "tagline": fields.Str(validate=validate.Length(max=100)), "description": fields.Str(validate=validate.Length(min=100, max=10000)), "website": fields.Url(), "status": EnumField(ModStatus), "category": EnumField(ModCategory), "authors": fields.List(fields.Nested(AuthorSchema)), "icon": fields.Str(validate=validate.Regexp( DATA_URI_RE, error= ("`icon` should be a data uri like 'data:image/png;base64,<data>' or " "'data:image/jpeg;base64,<data>'"), )), "banner": fields.Str(validate=validate.Regexp( DATA_URI_RE, error= ("`banner` should be a data uri like 'data:image/png;base64,<data>' or " "'data:image/jpeg;base64,<data>'"), )), "color": EnumField(ModColor), "is_private_beta": fields.Bool(), "mod_playtester": fields.List(fields.Str()), }, locations=("json", ), ) async def patch_mod( self, mod_id: str = None, authors: List[dict] = None, mod_playtester: List[str] = None, icon: str = None, banner: str = None, **kwargs, ): if not await Mod.exists(mod_id): abort(404, "Unknown mod") mod = await Mod.get(mod_id) updates = mod.update(**kwargs) if authors is not None: authors = [ author for author in authors if await User.exists(author["id"]) ] # TODO: if user is owner or co-owner, allow them to change the role of others to ones below them. authors = [ author for author in authors if not await ModAuthor.query.where( and_(ModAuthor.user_id == author["id"], ModAuthor.mod_id == mod_id)).gino.first() ] if mod_playtester is not None: for playtester in mod_playtester: if not await User.exists(playtester): abort(400, f"Unknown user '{playtester}'") elif await ModPlaytester.query.where( and_( ModPlaytester.user_id == playtester, ModPlaytester.mod_id == mod.id, )).gino.all(): abort(400, f"{playtester} is already enrolled.") if icon is not None: icon_mimetype, icon_data = validate_img(icon, "icon") icon_data = base64.b64decode(icon_data) icon_ext = icon_mimetype.split("/")[1] icon_resp = await ipfs_upload(icon_data, f"icon.{icon_ext}", self.core.aioh_sess) updates = updates.update(icon=icon_resp["Hash"]) if banner is not None: banner_mimetype, banner_data = validate_img(banner, "banner") banner_data = base64.b64decode(banner_data) banner_ext = banner_mimetype.split("/")[1] banner_resp = await ipfs_upload(banner_data, f"banner.{banner_ext}", self.core.aioh_sess) updates = updates.update(banner=banner_resp["Hash"]) await updates.apply() await ModAuthor.insert().gino.all(*[ dict(user_id=author["id"], mod_id=mod.id, role=author["role"]) for author in authors ]) await ModPlaytester.insert().gino.all( *[dict(user_id=user, mod_id=mod.id) for user in ModPlaytester]) return jsonify(mod.to_dict()) # TODO: decline route with reason, maybe doesn't 100% delete it? idk @multiroute("/api/v1/mods/<mod_id>", methods=["DELETE"], other_methods=["GET", "PATCH"]) @requires_login @json async def delete_mod(self, mod_id: str): await Mod.delete.where(Mod.id == mod_id).gino.status() return jsonify(True) @route("/api/v1/mods/<mod_id>/download") @json async def get_download(self, mod_id: str): user_id = await get_token_user() mod = await Mod.get(mod_id) if mod is None: abort(404, "Unknown mod") if user_id is None and mod.is_private_beta: abort(403, "Private beta mods requires authentication.") if not await ModPlaytester.query.where( and_(ModPlaytester.user_id == user_id, ModPlaytester.mod_id == mod.id)).gino.all(): abort(403, "You are not enrolled to the private beta.") elif not mod.zip_url: abort(404, "Mod has no download") return jsonify(url=mod.zip_url) @multiroute("/api/v1/mods/<mod_id>/reviews", methods=["GET"], other_methods=["POST"]) @json @use_kwargs( { "page": fields.Int(missing=0), "limit": fields.Int(missing=10), "rating": UnionField( [ fields.Int(validate=validate.OneOf([1, 2, 3, 4, 5])), fields.Str(validate=validate.Equal("all")), ], missing="all", ), "sort": EnumField(ReviewSorting, missing=ReviewSorting.best), }, locations=("query", ), ) async def get_reviews( self, mod_id: str, page: int, limit: int, rating: Union[int, str], sort: ReviewSorting, ): if not await Mod.exists(mod_id): abort(404, "Unknown mod") if not 1 <= limit <= 25: limit = max(1, min( limit, 25)) # Clamp `limit` to 1 or 100, whichever is appropriate page = page - 1 if page > 0 else 0 upvote_aggregate = (select([func.count()]).where( and_( ReviewReaction.review_id == Review.id, ReviewReaction.reaction == ReactionType.upvote, )).as_scalar()) downvote_aggregate = (select([func.count()]).where( and_( ReviewReaction.review_id == Review.id, ReviewReaction.reaction == ReactionType.downvote, )).as_scalar()) funny_aggregate = (select([func.count()]).where( and_( ReviewReaction.review_id == Review.id, ReviewReaction.reaction == ReactionType.funny, )).as_scalar()) query = Review.load( author=User, upvotes=upvote_aggregate, downvotes=downvote_aggregate, funnys=funny_aggregate, ).where(Review.mod_id == mod_id) user_id = await get_token_user() if user_id: did_upvote_q = (select([func.count()]).where( and_( ReviewReaction.review_id == Review.id, ReviewReaction.user_id == user_id, ReviewReaction.reaction == ReactionType.upvote, )).limit(1).as_scalar()) did_downvote_q = (select([func.count()]).where( and_( ReviewReaction.review_id == Review.id, ReviewReaction.user_id == user_id, ReviewReaction.reaction == ReactionType.downvote, )).limit(1).as_scalar()) did_funny_q = (select([func.count()]).where( and_( ReviewReaction.review_id == Review.id, ReviewReaction.user_id == user_id, ReviewReaction.reaction == ReactionType.funny, )).limit(1).as_scalar()) query = query.gino.load( user_reacted_upvote=did_upvote_q, user_reacted_downvote=did_downvote_q, user_reacted_funny=did_funny_q, ) if review_sorters[sort]: query = query.order_by(review_sorters[sort]) elif sort == ReviewSorting.best: query = query.order_by(upvote_aggregate - downvote_aggregate) elif sort == ReviewSorting.funniest: query = query.order_by(funny_aggregate.desc()) if isinstance(rating, int): values = [rating, rating + 0.5] if rating == 1: # Also get reviews with a 0.5 star rating, otherwise they'll never appear. values.append(0.5) query = query.where(Review.rating.in_(values)) reviews = await paginate(query, page, limit).gino.all() total = await query.alias().count().gino.scalar() return jsonify(total=total, page=page, limit=limit, results=self.dict_all(reviews)) @multiroute("/api/v1/mods/<mod_id>/reviews", methods=["POST"], other_methods=["GET"]) @requires_login @json @use_kwargs({ "rating": fields.Int( required=True, validate=[ # Only allow increments of 0.5, up to 5. lambda x: 5 >= x >= 1, lambda x: x % 0.5 == 0, ], ), "content": fields.Str(required=True, validate=validate.Length(max=2000)), "title": fields.Str(required=True, validate=validate.Length(max=32)), }) async def post_review(self, mod_id: str, rating: int, content: str, title: str): if not await Mod.exists(mod_id): abort(404, "Unknown mod") user_id = await get_token_user() if await Review.query.where( and_(Review.author_id == user_id, Review.mod_id == mod_id)).gino.first(): abort(400, "Review already exists") review = await Review.create( title=title, content=content, rating=rating, author_id=user_id, mod_id=mod_id, ) return jsonify(review.to_json()) @route("/api/v1/reviews/<review_id>/react", methods=["POST"]) @requires_login @json @use_kwargs({ "undo": fields.Bool(missing=False), "type": EnumField(ReactionType, data_key="type", required=True), }) async def react_review(self, review_id: str, undo: bool, type_: EnumField): user_id = await get_token_user() where_opts = [ ReviewReaction.review_id == review_id, ReviewReaction.user_id == user_id, ReviewReaction.reaction == type_, ] create_opts = { "review_id": review_id, "user_id": user_id, "reaction": type_ } exists = bool(await ReviewReaction.select("id").where(and_(*where_opts) ).gino.scalar()) if (exists and not undo) or (not exists and undo): pass elif not undo: if type_ == ReactionType.upvote: # Negate any downvotes by the user as upvoting and downvoting at the same time is stupid await ReviewReaction.delete.where( and_(*where_opts[:-1], ReviewReaction.type == ReactionType.downvote) ).gino.status() elif type_ == ReactionType.downvote: # Ditto for any upvotes if trying to downvote await ReviewReaction.delete.where( and_(*where_opts[:-1], ReviewReaction.type == ReactionType.downvote) ).gino.status() await ReviewReaction.create(**create_opts) else: await ReviewReaction.delete.where(and_(*where_opts)).gino.status() # Return user's current reaction results results = await ReviewReaction.query.where(and_(*where_opts[:-1]) ).gino.all() results = [x.reaction for x in results] return jsonify( upvote=ReactionType.upvote in results, downvote=ReactionType.downvote in results, funny=ReactionType.funny in results, ) # This handles POST requests to add zip_url. # Usually this would be done via a whole entry but this # is designed for existing content. @route("/api/v1/mods/<mod_id>/upload_content", methods=["POST"]) @json @requires_supporter @requires_login async def upload(self, mod_id: str): if not await Mod.exists(mod_id): abort(404, "Unknown mod") abort(501, "Coming soon") @route("/api/v1/mods/<mod_id>/report", methods=["POST"]) @json @use_kwargs( { "content": fields.Str(required=True, validate=validate.Length(min=100, max=1000)), "type_": EnumField(ReportType, data_key="type", required=True), "recaptcha": fields.Str(required=True), }, locations=("json", ), ) @requires_login @limiter.limit("2 per hour") async def report_mod(self, mod_id: str, content: str, type_: ReportType, recaptcha: str): score = await verify_recaptcha(recaptcha, self.core.aioh_sess) if score < 0.5: # TODO: send email/other 2FA when below 0.5 abort(400, "Possibly a bot") user_id = await get_token_user() report = await Report.create(content=content, author_id=user_id, mod_id=mod_id, type=type_) return jsonify(report.to_dict())
class PricesResource(Resource): class PriceSchema(ma.ModelSchema): class ProductSchema(ma.ModelSchema): class ProductTagSchema(ma.ModelSchema): name = fields.String() @post_dump def flatten(self, data): return data['name'] productId = fields.Int(attribute="id") productName = fields.String(attribute="name") productTags = fields.Nested(ProductTagSchema, many=True, attribute='tags') class ShopSchema(ma.ModelSchema): class ShopTagSchema(ma.ModelSchema): name = fields.String() @post_dump def flatten(self, data): return data['name'] shopId = fields.Int(attribute='id') shopName = fields.Str(attribute='name') shopTags = fields.Nested(ShopTagSchema, many=True, attribute='tags') shopAddress = fields.Str(attribute='address') price = fields.Float() date = fields.Date() product = fields.Nested(ProductSchema) shop = fields.Nested(ShopSchema) shopDist = fields.Float(attribute='dist') @pre_dump def handle_tuple(self, data): # if with_geo: query result = list of tuple(product, distance) if not isinstance(data, tuple): return data data[0].dist = data[1] return data[0] @post_dump def refactor(self, data): # flatten shop, product entries of data dict t_shop = data['shop'] t_prod = data['product'] del data['shop'] del data['product'] data.update(t_shop) data.update(t_prod) return data @use_args({ 'start': fields.Int(missing=0, location='query', validate=validate.Range(min=0)), 'count': fields.Int(missing=20, location='query', validate=validate.Range(min=0)), # zero count is ok ? 'geoDist': fields.Float(missing=None, location='query'), 'geoLng': fields.Float(missing=None, location='query'), 'geoLat': fields.Float(missing=None, location='query'), 'dateFrom': fields.Date(missing=None, location='query'), 'dateTo': fields.Date(missing=None, location='query'), 'sort': fields.List(fields.Str(validate=validate.OneOf(SORT_CHOICE)), missing=['price|ASC'], location='query'), 'shops': fields.List(fields.Int(), missing=None, location='query'), 'products': fields.List(fields.Int(), missing=None, location='query'), 'tags': fields.List(fields.Str(), missing=None, location='query'), 'format': fields.Str(missing='json', location='query', validate=validate.Equal('json')) }) def get(self, args): shop_ids = args['shops'] product_ids = args['products'] tags = args['tags'] with_geo = args['geoDist'] is not None and args[ 'geoLng'] is not None and args['geoLat'] is not None no_geo = args['geoDist'] is None and args['geoLng'] is None and args[ 'geoLat'] is None if not (with_geo or no_geo): return custom_error( 'geo', ['Invalid geo parameters combination']), ErrorCode.BAD_REQUEST with_date = (args['dateFrom'] is not None and args['dateTo'] is not None) and \ (args['dateFrom'] <= args['dateTo']) no_date = args['dateFrom'] is None and args['dateTo'] is None if not (with_date or no_date): return custom_error( 'date', ['Invalid date parameters combination']), ErrorCode.BAD_REQUEST sorts = [x.split('|') for x in args['sort']] for sort in sorts: if (sort[0] == 'geoDist' and no_geo) or (sort[0] == 'date' and no_date): return custom_error( 'sort', ['Invalid sort parameter']), ErrorCode.BAD_REQUEST # extra validation, some combinations are illegal dist_col = None if with_geo: dist = func.ST_Distance(Shop.position, from_shape(Point(args['geoLng'], args['geoLat']), srid=4326), True).\ label('dist') query = db.session.query(Price, dist) else: query = Price.query query = query.join(Price.product, Price.shop) today = date.today() date_from = args['dateFrom'] if args['dateFrom'] else today date_to = args['dateTo'] if args['dateTo'] else today query = query.filter(Price.date.between(date_from, date_to)) if shop_ids: query = query.filter(Shop.id.in_(shop_ids)) if product_ids: query = query.filter(Product.id.in_(product_ids)) if tags: query = query.filter( or_( Product.tags.any( func.lower(ProductTag.name).in_(map(str.lower, tags))), Shop.tags.any( func.lower(ShopTag.name).in_(map(str.lower, tags))))) if with_geo: subq = query.subquery() dist_col = subq.c.dist query = db.session.query(Price, dist_col).select_entity_from(subq).filter( dist_col < 1000 * args['geoDist']) # nested query to avoid recalculating distance expr # WHERE and HAVING clauses can't refer to column names (dist) def to_sort_operator(field, order): sort_field = { 'geoDist': dist_col, 'price': Price.price, 'date': Price.date }[field] sort_order = {'ASC': asc, 'DESC': desc}[order] return sort_order(sort_field) query = query.order_by( *[to_sort_operator(field, order) for field, order in sorts]) start = args['start'] count = args['count'] total = query.count() query = query.offset(start).limit(count) prices_page = query.all() prices = PricesResource.PriceSchema(many=True).dump(prices_page).data return { 'start': start, 'count': count, 'total': total, 'prices': prices } @requires_auth @use_args({ 'price': fields.Float(required=True, location='form'), 'dateFrom': fields.Date(required=True, location='form'), 'dateTo': fields.Date(required=True, location='form'), 'productId': fields.Int(required=True, attribute='product_id', location='form'), 'shopId': fields.Int(required=True, attribute='shop_id', location='form'), 'format': fields.Str(missing='json', location='query', validate=validate.Equal('json')) }) def post(self, args, **_kwargs): date1 = args['dateFrom'] date2 = args['dateTo'] if not (date1 <= date2): return custom_error( 'date', ['date(From) must be before date(To)']), ErrorCode.BAD_REQUEST shop = Shop.query.filter_by(id=args['shop_id']).first() product = Product.query.filter_by(id=args['product_id']).first() if not shop: return custom_error('shop', ['Invalid shop id']), ErrorCode.NOT_FOUND elif not product: return custom_error('product', ['Invalid product id']), ErrorCode.NOT_FOUND del args['format'] del args['dateFrom'] del args['dateTo'] for d in [ date1 + timedelta(days=x) for x in range((date2 - date1).days + 1) ]: new_price = dict(**args, date=d) upsert = insert(Price).values(new_price).on_conflict_do_update( constraint="price_psd_c", set_=new_price) db.session.execute(upsert) db.session.commit() new_prices = Price.query.filter(Price.product_id == args['product_id'], Price.shop_id == args['shop_id'], Price.date.between(date1, date2)).all() return { 'start': 0, 'total': (date2 - date1).days + 1, 'count': (date2 - date1).days + 1, # why you ask ? no idea, field must be present, can't find any sensible value 'prices': PricesResource.PriceSchema(many=True).dump(new_prices).data }