diff --git a/CodeChallenge/__init__.py b/CodeChallenge/__init__.py index 2e0b6f4..39a3add 100644 --- a/CodeChallenge/__init__.py +++ b/CodeChallenge/__init__.py @@ -11,6 +11,7 @@ from . import core from .api.eb import bp as eb_bp from .api.questions import bp as questions_bp +from .api.slack import bp as slack_bp from .api.users import bp as users_bp from .api.vote import bp as vote_bp from .auth import jwt @@ -62,6 +63,7 @@ def create_app(config): app.register_blueprint(q_cli_bp) app.register_blueprint(clock_cli_bp) app.register_blueprint(vote_bp) + app.register_blueprint(slack_bp) @app.errorhandler(429) def ratelimit_handler(e): diff --git a/CodeChallenge/api/eb.py b/CodeChallenge/api/eb.py index 9ea0c3f..fb0196f 100644 --- a/CodeChallenge/api/eb.py +++ b/CodeChallenge/api/eb.py @@ -1,5 +1,6 @@ from hmac import compare_digest +import requests from flask import Blueprint, request, current_app, render_template from flask_mail import Message @@ -41,4 +42,10 @@ def worker(): mail.send(msg) + webhook = current_app.config.get("SLACK_WEBHOOK") + if webhook is not None: + requests.post(webhook, json=dict( + text=f"*NEW RANK* {core.current_rank()}" + )) + return "", 200 diff --git a/CodeChallenge/api/slack.py b/CodeChallenge/api/slack.py new file mode 100644 index 0000000..3b569b9 --- /dev/null +++ b/CodeChallenge/api/slack.py @@ -0,0 +1,91 @@ +import hmac +import re +import time + +import requests +from flask import Blueprint, request, jsonify, current_app, abort +from sqlalchemy import func + +from .. import core +from ..auth import Users +from ..models import db + +bp = Blueprint("slackapi", __name__, url_prefix="/api/v1/slack") + +CMDRE = re.compile(r"^!([^ ]+)", re.I) + + +@bp.before_request +def slack_verify(): + ts = request.headers.get("X-Slack-Request-Timestamp") + secret = bytes(current_app.config.get("SLACK_SIGNING_SECRET"), "utf8") + body = request.data + + if abs(time.time() - int(ts)) > 60 * 5: + abort(401) + + sig_basestring = bytes(f"v0:{ts}:", "utf8") + sig_basestring += body + + my_sig = "v0=" + hmac.new(secret, sig_basestring, digestmod="SHA256").hexdigest() + slack_sig = request.headers.get("X-Slack-Signature") + if not hmac.compare_digest(my_sig, slack_sig): + abort(401) + + +def post_message(channel, text): + rv = requests.post("https://slack.com/api/chat.postMessage", + headers=dict( + Authorization="Bearer " + current_app.config["SLACK_OAUTH_TOKEN"] + ), + json=dict( + channel=channel, + text=text + )) + + rv.raise_for_status() + + +def handle_message(text, channel): + match = CMDRE.search(text) + + if match is None: + return + + command = match.group(1) + + if command == "status": + rank = core.current_rank() + resp = f"*Current Rank:* {rank if rank != -1 else '(challenge not started)'}\n" + resp += f"*Max Rank:* {core.max_rank()}\n" + resp += f"*Next Rank:* {core.time_until_next_rank()}\n" + resp += f"*Total Users:* {core.user_count()}" + + post_message(channel, resp) + + if command == "foundus": + found_us = db.session.query(Users.found_us, + func.count(Users.found_us)) \ + .group_by(Users.found_us) \ + .order_by(Users.found_us) \ + .all() + + resp = "" + for row in found_us: + resp += f"*{row[0]}*: {row[1]}\n" + + post_message(channel, resp) + + +@bp.route("/event", methods=["POST"]) +def slack_event(): + data = request.get_json() + + if "challenge" in data: + return jsonify(challenge=data["challenge"]) + + event = data["event"] + if event["type"] == "message": + handle_message(event["text"], event["channel"]) + + return "", 200 diff --git a/CodeChallenge/api/users.py b/CodeChallenge/api/users.py index cb7f754..2c64337 100644 --- a/CodeChallenge/api/users.py +++ b/CodeChallenge/api/users.py @@ -1,3 +1,4 @@ +import requests from flask import Blueprint, jsonify, request, current_app, render_template from flask_jwt_extended import (create_access_token, create_refresh_token, get_current_user, get_jwt_identity, @@ -6,6 +7,7 @@ unset_jwt_cookies) from flask_limiter.util import get_remote_address from flask_mail import Message +from sqlalchemy import func from .. import core from ..auth import (Users, hash_password, password_reset_token, @@ -146,8 +148,7 @@ def register(): name = new_u.studentfirstname or new_u.parentfirstname confirm_email.html = render_template("challenge_account_confirm.html", name=name, - username=new_u.username, - password=password) + username=new_u.username) confirm_email.extra_headers = {"List-Unsubscribe": "%unsubscribe_email%"} # welcome email @@ -162,6 +163,18 @@ def register(): mail.send(confirm_email) mail.send(welcome_email) + if "SLACK_WEBHOOK" in current_app.config and not current_app.config.get("TESTING", False): + regcount = db.session.query(func.count(Users.id)).scalar() + webhook = current_app.config.get("SLACK_WEBHOOK") + requests.post(webhook, json=dict( + text="Event: New Registration\n\n" + f"*User*: {new_u.username}\n" + f"*Student*: {new_u.studentfirstname} {new_u.studentlastname}\n" + f"*Parent*: {new_u.parentfirstname} {new_u.parentlastname}\n" + f"*How'd you find us?* {new_u.found_us}\n" + f"\n*Total Registrations*: {regcount}" + )) + return jsonify({"status": "success"}) diff --git a/CodeChallenge/api/vote.py b/CodeChallenge/api/vote.py index f9629a9..faf5817 100644 --- a/CodeChallenge/api/vote.py +++ b/CodeChallenge/api/vote.py @@ -55,10 +55,15 @@ def get_contestants(): display = f"{ans.user.studentfirstname} " \ f"{ans.user.studentlastname[0]}." + confirmed_votes = [] + for v in ans.votes: + if v.confirmed: + confirmed_votes.append(v) + contestants.append(dict( id=ans.id, text=ans.text, - numVotes=len(ans.votes), + numVotes=len(confirmed_votes), firstName=ans.user.studentfirstname, lastName=ans.user.studentlastname, username=ans.user.username, diff --git a/CodeChallenge/config.py b/CodeChallenge/config.py index 1434492..f52d8b6 100644 --- a/CodeChallenge/config.py +++ b/CodeChallenge/config.py @@ -30,6 +30,10 @@ class DefaultConfig: MG_PRIVATE_KEY = os.getenv("MG_PRIVATE_KEY") MG_LIST = "codechallenge@school.codewizardshq.com" WORKER_PASSWORD = os.getenv("WORKER_PASSWORD") + SLACK_WEBHOOK = os.getenv("SLACK_WEBHOOK") + SLACK_SIGNING_SECRET = os.getenv("SLACK_SIGNING_SECRET") + SLACK_OAUTH_TOKEN = os.getenv("SLACK_OAUTH_TOKEN") + SLACK_CHANNEL = os.getenv("SLACK_CHANNEL") # no trailing / EXTERNAL_URL = "https://challenge.codewizardshq.com" diff --git a/CodeChallenge/core.py b/CodeChallenge/core.py index 81c746c..cde9aa2 100644 --- a/CodeChallenge/core.py +++ b/CodeChallenge/core.py @@ -3,6 +3,7 @@ from flask import current_app from sqlalchemy import func +from .auth import Users from .models import Question, db @@ -68,3 +69,7 @@ def challenge_ended() -> bool: return True return False + + +def user_count() -> int: + return db.session.query(func.count(Users.id)).scalar() diff --git a/CodeChallenge/templates/challenge_account_confirm.html b/CodeChallenge/templates/challenge_account_confirm.html index c7c516d..2261017 100644 --- a/CodeChallenge/templates/challenge_account_confirm.html +++ b/CodeChallenge/templates/challenge_account_confirm.html @@ -295,7 +295,6 @@

{{name}}, your account has been created.


Login: {{username}}

-

Password: {{password}}


Forgot your password? Reset your password.

diff --git a/src/api/voting.js b/src/api/voting.js index 5b07932..b2b0cfd 100644 --- a/src/api/voting.js +++ b/src/api/voting.js @@ -1,8 +1,10 @@ import routes from "./routes"; import request from "./request"; -async function getBallot() { - return request(routes.voting_ballot); +async function getBallot(page, per) { + return request(routes.voting_ballot, { + params: { page, per } + }); } async function cast(answerId, email) { diff --git a/src/components/MobileWarning.vue b/src/components/MobileWarning.vue new file mode 100644 index 0000000..20c0eee --- /dev/null +++ b/src/components/MobileWarning.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/QuizNeedHelp.vue b/src/components/QuizNeedHelp.vue index 1d6a236..10bc983 100644 --- a/src/components/QuizNeedHelp.vue +++ b/src/components/QuizNeedHelp.vue @@ -9,16 +9,19 @@ - + mdi-{{ icon.icon }} + Check out the source code on GitHub! @@ -33,19 +36,21 @@ export default { dialog: false, icons: [ { - icon: "twitter" + icon: "twitter", + to: "https://twitter.com/CodeWizardsHQ" }, { - icon: "facebook" + icon: "facebook", + to: "https://www.facebook.com/events/501020200554546/" }, { - icon: "whatsapp" + icon: "linkedin", + to: "https://www.linkedin.com/company/codewizardshq" }, { - icon: "linkedin" - }, - { - icon: "email" + icon: "email", + to: + "mailto:?subject=Join%20me%20in%20the%20CodeWizardsHQ%20Code%20Challenge!" } ] }; diff --git a/src/components/SocialPopOver.vue b/src/components/SocialPopOver.vue index 938efc6..360beb9 100644 --- a/src/components/SocialPopOver.vue +++ b/src/components/SocialPopOver.vue @@ -19,7 +19,7 @@ :cols="i == icons.length - 1 ? 12 : 6" class="text-center" > - + mdi-{{ icon.icon }} @@ -35,19 +35,21 @@ export default { isOpen: false, icons: [ { - icon: "twitter" + icon: "twitter", + to: "https://twitter.com/CodeWizardsHQ" }, { - icon: "facebook" + icon: "facebook", + to: "https://www.facebook.com/events/501020200554546/" }, { - icon: "whatsapp" + icon: "linkedin", + to: "https://www.linkedin.com/company/codewizardshq" }, { - icon: "linkedin" - }, - { - icon: "email" + icon: "email", + to: + "mailto:?subject=Join%20me%20in%20the%20CodeWizardsHQ%20Code%20Challenge!" } ] }) diff --git a/src/components/Toolbars/CWHQBar.vue b/src/components/Toolbars/CWHQBar.vue index f2e1a48..5738725 100644 --- a/src/components/Toolbars/CWHQBar.vue +++ b/src/components/Toolbars/CWHQBar.vue @@ -1,10 +1,7 @@ diff --git a/src/components/Toolbars/QuizBar.vue b/src/components/Toolbars/QuizBar.vue index 72825ba..e3174df 100644 --- a/src/components/Toolbars/QuizBar.vue +++ b/src/components/Toolbars/QuizBar.vue @@ -40,10 +40,13 @@ Check The FAQ - + Get Help On Discord - + Get Help On Facebook diff --git a/src/store/quiz.js b/src/store/quiz.js index ee78905..53af4d3 100644 --- a/src/store/quiz.js +++ b/src/store/quiz.js @@ -3,6 +3,25 @@ import { quiz } from "@/api"; import moment from "moment"; import Vue from "vue"; +moment.updateLocale("en", { + relativeTime: { + future: "in %s", + past: "%s ago", + s: "a few seconds", + ss: "%d seconds", + m: "a minute", + mm: "%d minutes", + h: "an hour", + hh: "%d hours", + d: "a day", + dd: "%d days", + M: "one month", + MM: "%d months", + y: "a year", + yy: "%d years" + } +}); + const moduleName = "Quiz"; function parseDateResponse(dateResponse) { diff --git a/src/styles/mobile-warning.scss b/src/styles/mobile-warning.scss new file mode 100644 index 0000000..b65506d --- /dev/null +++ b/src/styles/mobile-warning.scss @@ -0,0 +1,18 @@ +.mobile-warning { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: #21252d; + color: white; + z-index: 10000; + text-align: center; + display: block; +} + +@media (min-width: 600px) { + .mobile-warning { + display: none !important; + } +} diff --git a/src/styles/styles.scss b/src/styles/styles.scss index c5d9c4c..7b88cc4 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -1,19 +1,20 @@ .cwhq-app { - @import "./colors.scss"; - @import "./application.scss"; - @import "./quiz-scroll.scss"; - @import "./quiz-answer.scss"; - @import "./quiz-need-help.scss"; - @import "./quiz-bar-rank.scss"; - @import "./content.scss"; - @import "./social-pop-over.scss"; - @import "./casing.scss"; - @import "./cwhq-bar.scss"; - @import "./quiz-bar.scss"; - @import "./leaderboard-bar.scss"; - @import "./ballot-card.scss"; - @import "./ballot-modal.scss"; - @import "./buttons.scss"; + @import './colors.scss'; + @import './application.scss'; + @import './quiz-scroll.scss'; + @import './quiz-answer.scss'; + @import './quiz-need-help.scss'; + @import './quiz-bar-rank.scss'; + @import './content.scss'; + @import './social-pop-over.scss'; + @import './casing.scss'; + @import './cwhq-bar.scss'; + @import './quiz-bar.scss'; + @import './leaderboard-bar.scss'; + @import './ballot-card.scss'; + @import './ballot-modal.scss'; + @import './buttons.scss'; + @import './mobile-warning.scss'; .v-alert { font-size: 20px; @@ -21,6 +22,7 @@ .v-input { font-size: 20px; + margin-bottom: 12px; } .v-messages { @@ -34,3 +36,14 @@ } } } + +.v-form { + .v-select__slot { + padding-top: 10px; + } + + label { + font-size: 22px !important; + top: -2px; + } +} diff --git a/src/views/Accounts/Register/Step1.vue b/src/views/Accounts/Register/Step1.vue index 4714151..dd7cf13 100644 --- a/src/views/Accounts/Register/Step1.vue +++ b/src/views/Accounts/Register/Step1.vue @@ -10,26 +10,27 @@ color="input" v-bind="fields.username" v-model="fields.username.value" - :disabled="!allowSubmit" + :disabled="isSubmitting" /> - + - + You are not 13 years of age.

@@ -82,28 +88,13 @@ diff --git a/src/views/Voting/Ballot.vue b/src/views/Voting/Ballot.vue index f016711..4b69f6a 100644 --- a/src/views/Voting/Ballot.vue +++ b/src/views/Voting/Ballot.vue @@ -3,21 +3,34 @@

- Lorem Ipsum is simply dummy text of the printing and typesetting - industry. Lorem Ipsum has been the industry's standard dummy text ever - since the 1500s, when an unknown printer took a galley of type and - scrambled it to make a type specimen book. + Cast your vote below!

- + + + +

+ Loading Results
Please Wait
+

+
+
+ - + /> + + + @@ -36,12 +49,14 @@ export default { }, data() { return { + isLoading: true, + per: 16, item: null, showModal: false, hasNext: false, hasPrev: false, nextNum: false, - page: 0, + page: 1, prevNum: null, totalItems: 0, totalPages: 0, @@ -53,30 +68,28 @@ export default { showCode(item) { this.item = item; this.showModal = true; + }, + async loadPage() { + this.isLoading = true; + try { + const results = await voting.getBallot(this.page, this.per); + for (const [key, value] of Object.entries(results)) { + Vue.set(this, key, value); + } + // console.log(results); + } catch (err) { + this.$router.push({ name: "redirect" }); + } + this.isLoading = false; } }, - async mounted() { - // for (let i = 0; i < 100; i++) { - // this.items.push({ - // display: "Kyle A.", - // firstName: "Kyle", - // id: 45, - // lastName: "Askew", - // numVotes: 0, - // text: - // "function calculateAnswer(){\n return 100;\n}\nvar output = calculateAnswer();;output", - // username: "net8floz2" - // }); - // } - try { - const results = await voting.getBallot(); - for (const [key, value] of Object.entries(results)) { - Vue.set(this, key, value); - } - // console.log(results); - } catch (err) { - this.$router.push({ name: "redirect" }); + watch: { + page(val) { + this.loadPage(val); } + }, + async mounted() { + this.loadPage(); } }; diff --git a/src/views/Voting/CodeModal.vue b/src/views/Voting/CodeModal.vue index 70b3092..c074c0b 100644 --- a/src/views/Voting/CodeModal.vue +++ b/src/views/Voting/CodeModal.vue @@ -51,22 +51,9 @@
- - - - We have sent a voting confirmation e-mail. - - - Please check your inbox to confirm your vote. - - - - Okay - - - + + + @@ -77,7 +64,7 @@
- Okay @@ -88,8 +75,12 @@ + +