From 011c20c866f6c7812ecf95cb621180ee5eb06476 Mon Sep 17 00:00:00 2001 From: bot50 Date: Thu, 25 Apr 2024 20:53:19 +0000 Subject: [PATCH] kukemuna-cs50/problems/2024/x/finance@20240425T205319.253305768Z --- app.py | 268 ++++++++++++++++++ finance.db | Bin 0 -> 20480 bytes .../2029240f6d1128be89ddc32729463129 | Bin 0 -> 9 bytes helpers.py | 88 ++++++ requirements.txt | 5 + static/I_heart_validator.png | Bin 0 -> 345 bytes static/favicon.ico | Bin 0 -> 15406 bytes static/styles.css | 23 ++ templates/apology.html | 11 + templates/buy.html | 17 ++ templates/history.html | 30 ++ templates/home.html | 40 +++ templates/layout.html | 91 ++++++ templates/login.html | 17 ++ templates/quote.html | 23 ++ templates/register.html | 20 ++ templates/sell.html | 22 ++ 17 files changed, 655 insertions(+) create mode 100644 app.py create mode 100644 finance.db create mode 100644 flask_session/2029240f6d1128be89ddc32729463129 create mode 100644 helpers.py create mode 100644 requirements.txt create mode 100644 static/I_heart_validator.png create mode 100644 static/favicon.ico create mode 100644 static/styles.css create mode 100644 templates/apology.html create mode 100644 templates/buy.html create mode 100644 templates/history.html create mode 100644 templates/home.html create mode 100644 templates/layout.html create mode 100644 templates/login.html create mode 100644 templates/quote.html create mode 100644 templates/register.html create mode 100644 templates/sell.html diff --git a/app.py b/app.py new file mode 100644 index 0000000..3f1cf6b --- /dev/null +++ b/app.py @@ -0,0 +1,268 @@ +import os +import datetime + +from cs50 import SQL +from flask import Flask, flash, redirect, render_template, request, session +from flask_session import Session +from werkzeug.security import check_password_hash, generate_password_hash + +from helpers import apology, login_required, lookup, usd + +# Configure application +app = Flask(__name__) + +# Custom filter +app.jinja_env.filters["usd"] = usd + +# Configure session to use filesystem (instead of signed cookies) +app.config["SESSION_PERMANENT"] = False +app.config["SESSION_TYPE"] = "filesystem" +Session(app) + +# Configure CS50 Library to use SQLite database +db = SQL("sqlite:///finance.db") + + +@app.after_request +def after_request(response): + """Ensure responses aren't cached""" + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Expires"] = 0 + response.headers["Pragma"] = "no-cache" + return response + + +@app.route("/") +@login_required +def index(): + """Show portfolio of stocks""" + user_id = session["user_id"] + + cash = db.execute("SELECT * FROM users where id = ?", user_id) + + stocks = db.execute( + "SELECT symbol, sum(shares) FROM transactions WHERE user_id = ? GROUP BY symbol HAVING sum(shares) > 0", user_id) + + stocks_total = 0 + + for stock in stocks: + + symbol = stock["symbol"] + stock_price = lookup(symbol.upper()) + stock["shares"] = stock["sum(shares)"] + stock["price"] = stock_price["price"] + stock["total"] = usd(stock_price["price"] * stock["sum(shares)"]) + stocks_total = stocks_total + (stock_price["price"] * stock["sum(shares)"]) + + total = usd(cash[0]["cash"] + stocks_total) + + return render_template("home.html", cash=usd(cash[0]["cash"]), stocks=stocks, total=total) + # return apology("MOFO") + + +@app.route("/buy", methods=["GET", "POST"]) +@login_required +def buy(): + """Buy shares of stock""" + if request.method == "GET": + return render_template("buy.html") + else: + symbol = request.form.get("symbol") + shares = float(request.form.get("shares")) + + if not symbol: + return apology("Not Symbol") + + stock = lookup(symbol.upper()) + + if stock == None: + return apology("Symbol not found") + + if not shares == "": + transaction_value = float(shares) * stock["price"] + + user_id = session["user_id"] + user_cash_db = db.execute("SELECT cash FROM users WHERE id = ?", user_id) + user_cash = user_cash_db[0]["cash"] + + if user_cash < transaction_value: + return apology("U broke, m8!") + + free_cash = user_cash - transaction_value + + db.execute("UPDATE users SET cash = ? WHERE id = ?", free_cash, user_id) + + date = datetime.datetime.now() + + db.execute("INSERT INTO transactions (user_id, symbol, shares, price, date) VALUES (?, ?, ?, ?, ?)", + user_id, stock["symbol"], shares, stock["price"], date) + + flash("Bought!") + + return redirect("/") + # return apology("TODO") + + +@app.route("/history") +@login_required +def history(): + """Show history of transactions""" + stocks = db.execute("SELECT * FROM transactions") + + return render_template("history.html", stocks=stocks) + # return apology("TODO") + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + """Log user in""" + + # Forget any user_id + session.clear() + + # User reached route via POST (as by submitting a form via POST) + if request.method == "POST": + # Ensure username was submitted + if not request.form.get("username"): + return apology("must provide username", 403) + + # Ensure password was submitted + elif not request.form.get("password"): + return apology("must provide password", 403) + + # Query database for username + rows = db.execute( + "SELECT * FROM users WHERE username = ?", request.form.get("username") + ) + + # Ensure username exists and password is correct + if len(rows) != 1 or not check_password_hash( + rows[0]["hash"], request.form.get("password") + ): + return apology("invalid username and/or password", 403) + + # Remember which user has logged in + session["user_id"] = rows[0]["id"] + + # Redirect user to home page + return redirect("/") + + # User reached route via GET (as by clicking a link or via redirect) + else: + return render_template("login.html") + + +@app.route("/logout") +def logout(): + """Log user out""" + + # Forget any user_id + session.clear() + + # Redirect user to login form + return redirect("/") + + +@app.route("/quote", methods=["GET", "POST"]) +@login_required +def quote(): + """Get stock quote.""" + if request.method == "POST": + + symbol = request.form.get("symbol") + if not symbol: + return apology("Not Symbol") + + stock = lookup(symbol.upper()) + + if stock == None: + return apology("Symbol not found") + + stock["price"] = usd(stock["price"]) + + return render_template("quote.html", stock=stock) + else: + return render_template("quote.html") + # return apology("TODO") + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + """Register user""" + if request.method == "POST": + # Ensure username was submitted + if not request.form.get("username"): + return apology("must provide username", 400) + + # Ensure password was submitted + elif not request.form.get("password"): + return apology("must provide password", 400) + + # Ensure password repeat matches + if not request.form.get("password") == request.form.get("confirmation"): + return apology("passwords don't match", 400) + + username = request.form.get("username") + password = request.form.get("password") + + username_exists = db.execute("SELECT * FROM users WHERE username = ?", username) + + if username_exists: + flash("Username exists already!") + return render_template("register.html") + + db.execute("INSERT INTO users(username, hash) VALUES(?, ?)", + username, generate_password_hash(password)) + return redirect("/") + else: + return render_template("register.html") + # return apology("MOFO") + + +@app.route("/sell", methods=["GET", "POST"]) +@login_required +def sell(): + """Sell shares of stock""" + user_id = session["user_id"] + + if request.method == "POST": + symbol = request.form.get("symbol") + shares = int(request.form.get("shares")) + + if not symbol: + return apology("Not Symbol") + + stock = lookup(symbol.upper()) + + if stock == None: + return apology("Symbol not found") + + stocks = db.execute( + "SELECT symbol, sum(shares) FROM transactions WHERE user_id = ? AND symbol = ?", user_id, symbol) + + if stocks[0]["sum(shares)"] < shares: + return apology("Not enough shares") + + transaction_value = shares * stock["price"] + + user_cash_db = db.execute("SELECT cash FROM users WHERE id = ?", user_id) + user_cash = user_cash_db[0]["cash"] + + new_cash = transaction_value + user_cash + + db.execute("UPDATE users SET cash = ? WHERE id = ?", new_cash, user_id) + + date = datetime.datetime.now() + + db.execute("INSERT INTO transactions (user_id, symbol, shares, price, date) VALUES (?, ?, ?, ?, ?)", + user_id, stock["symbol"], -abs(shares), stock["price"], date) + + flash("Sold!") + + return redirect("/") + + else: + stocks = db.execute( + "SELECT symbol FROM transactions WHERE user_id = ? GROUP BY symbol HAVING sum(shares) > 0", user_id) + return render_template("sell.html", stocks=stocks) + # return apology("TODO") diff --git a/finance.db b/finance.db new file mode 100644 index 0000000000000000000000000000000000000000..f4d242214a846ec488d0ab0994954188d7de1516 GIT binary patch literal 20480 zcmeI2OK2NM7{^!gL$RJt8&`eB-9W<18@DN8cQxAxEWhN? zJSfzrxfOCQw1<)&dTF49Lg+EQrl&%44uxC-p_hcvl(e&oos2CBQV4{UZ(w(4cmMOv z|NG7C!m=|{DK%EO>MtxeofVZR!%8Tmf00e*l5O@{@RzL2Kr1$Izt$nuQxO04E z*_m%S-b!O(z9rWO4o?^I)q+~hzgsG(@*Oo3ZPe9bxmp-6OsiAV#fkj%tomMIR?XL{ zlf|-VI8i89)$*j+YNgV?=;;<;J}I?%a;J5+=`PHv)xvRE-#X75fI$JJX?NW4_MM1U=6?O&%Ou{p%^!%Ed~pAi^Ih99K6K z>SVcHZ>gD$OJh^v!Svp}q1iU5*3z8#6h7JFOQ-p~$G4pJZNjzXW+t$(PqZ9c-5-ji z)9KKf)sES=)!v0SwQpZiGZM0=!2BeY%{bLlPV3Yb#HtruE7v9p)5XJ&S*k}0NAtB( zRn?Iw2M`)dq$ZVc;+B%QmHg&eOg@MK1b_e#00KY&2mk>f00e*l5C8(tlfWts4JspR zmB>t^(QJ9kXBSs&f{m>eM}6=Z@oI zZe$U2nCpAYXPj{2*LjvW%wUFRa>OaGGsb*JcXaAw1AC-yAOrcn!hU*%PxP)Z|6RHi1LH_Z14)lNS zJNP#rPW_>z?us20AOHk_01yBIKmZ5;0U!VbfB+Bx0zlwJB`_TB+Y^%4ckCJp^`%3P zRwYD5R^ikgC3Pot|3ys@d;00e*l5C8%|00;m9AOHk_01yBIGJzrS-QQkI5svhY z3(bJUyomk zpNm)Gnb<$E-(x?-zKDG!8bbjBKmZ5;0U!VbfB+Bx0{=e(2X;l2p^-?0OqHtB@2vkV z%dwxXT|AMwhY_ax5#5iOiiu6LHX@oD=tGb6fAtXRHb&Z8l5ntx5ZRPyqmuCN?m}YP z#L{+3!v3B@Y-w+<3-kG@(t{j7cjScNJ$>Y859+7S{U3*pXIMCgr5DGkLN!TM9AtYJt6-n4TqHbs-lE;Cb z9ud}dJcSV1jA}1ST?>zP!27(+5!=iPhVUgxC_J*!tCiaqHVdg?Geg@hb^Rd6zP~Fb z&fV+~>RBxzMGW_h zh>#YSB1UuU>r9Z%mWa&8REtRwg~x+kgoN5+K1QX80?(B$$q@0xB5g<#ie$QR)WySP zYTG2Ch+?HeFJt4A@lMzop3FhOVBn;=+m)Ax4wL>V98CVwvk83zr i|9SK5!R>bsv2{!9mm)@U^uCxoqgNkCd?ds+HU0%(Mq^R{ literal 0 HcmV?d00001 diff --git a/flask_session/2029240f6d1128be89ddc32729463129 b/flask_session/2029240f6d1128be89ddc32729463129 new file mode 100644 index 0000000000000000000000000000000000000000..60b84f8bf0af235343c89653c31a85c904ebfc66 GIT binary patch literal 9 QcmZQzU|?uq^=8lm00XQ5{{R30 literal 0 HcmV?d00001 diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..43349ca --- /dev/null +++ b/helpers.py @@ -0,0 +1,88 @@ +import csv +import datetime +import pytz +import requests +import urllib +import uuid + +from flask import redirect, render_template, request, session +from functools import wraps + + +def apology(message, code=400): + """Render message as an apology to user.""" + + def escape(s): + """ + Escape special characters. + + https://github.com/jacebrowning/memegen#special-characters + """ + for old, new in [ + ("-", "--"), + (" ", "-"), + ("_", "__"), + ("?", "~q"), + ("%", "~p"), + ("#", "~h"), + ("/", "~s"), + ('"', "''"), + ]: + s = s.replace(old, new) + return s + + return render_template("apology.html", top=code, bottom=escape(message)), code + + +def login_required(f): + """ + Decorate routes to require login. + + https://flask.palletsprojects.com/en/latest/patterns/viewdecorators/ + """ + + @wraps(f) + def decorated_function(*args, **kwargs): + if session.get("user_id") is None: + return redirect("/login") + return f(*args, **kwargs) + + return decorated_function + + +def lookup(symbol): + """Look up quote for symbol.""" + + # Prepare API request + symbol = symbol.upper() + end = datetime.datetime.now(pytz.timezone("US/Eastern")) + start = end - datetime.timedelta(days=7) + + # Yahoo Finance API + url = ( + f"https://query1.finance.yahoo.com/v7/finance/download/{urllib.parse.quote_plus(symbol)}" + f"?period1={int(start.timestamp())}" + f"&period2={int(end.timestamp())}" + f"&interval=1d&events=history&includeAdjustedClose=true" + ) + + # Query API + try: + response = requests.get( + url, + cookies={"session": str(uuid.uuid4())}, + headers={"Accept": "*/*", "User-Agent": request.headers.get("User-Agent")}, + ) + response.raise_for_status() + + # CSV header: Date,Open,High,Low,Close,Adj Close,Volume + quotes = list(csv.DictReader(response.content.decode("utf-8").splitlines())) + price = round(float(quotes[-1]["Adj Close"]), 2) + return {"price": price, "symbol": symbol} + except (KeyError, IndexError, requests.RequestException, ValueError): + return None + + +def usd(value): + """Format value as USD.""" + return f"${value:,.2f}" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..743fadd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +cs50 +Flask +Flask-Session +pytz +requests diff --git a/static/I_heart_validator.png b/static/I_heart_validator.png new file mode 100644 index 0000000000000000000000000000000000000000..c7db3d2bd567deed30dafc9f831d1e4d3aa56c10 GIT binary patch literal 345 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 + + {{ top }} +{% endblock %} diff --git a/templates/buy.html b/templates/buy.html new file mode 100644 index 0000000..b34ebb5 --- /dev/null +++ b/templates/buy.html @@ -0,0 +1,17 @@ +{% extends "layout.html" %} + +{% block title %} + Buy +{% endblock %} + +{% block main %} +
+
+ +
+
+ +
+ +
+{% endblock %} diff --git a/templates/history.html b/templates/history.html new file mode 100644 index 0000000..7384232 --- /dev/null +++ b/templates/history.html @@ -0,0 +1,30 @@ +{% extends "layout.html" %} + +{% block title %} + History +{% endblock %} + +{% block main %} + + + + + + + + + + + + {% for stock in stocks%} + + + + + + + {% endfor%} + +
SymbolSharesPriceTransacted
{{ stock.symbol }}{{ stock.shares }}{{ stock.price }}{{ stock.date }}
+ +{% endblock %} diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..e7fe1bb --- /dev/null +++ b/templates/home.html @@ -0,0 +1,40 @@ +{% extends "layout.html" %} + +{% block title %} + Home +{% endblock %} + +{% block main %} + + + + + + + + + + + + {% for stock in stocks%} + + + + + + + {% endfor%} + + + + + + + + + + + + +
SymbolSharesPriceTOTAL
{{ stock.symbol }}{{ stock.shares }}{{ stock.price }}{{ stock.total }}
Cash{{ cash }}
TOTAL{{ total }}
+{% endblock %} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..14d3053 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + C$50 Finance: {% block title %}{% endblock %} + + + + + + + + {% if get_flashed_messages() %} +
+ +
+ {% endif %} + +
+ {% block main %}{% endblock %} +
+ +
+ +

+ Data provided by Yahoo +

+ +
+ + + +
+ +
+ + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..0fe2d8e --- /dev/null +++ b/templates/login.html @@ -0,0 +1,17 @@ +{% extends "layout.html" %} + +{% block title %} + Log In +{% endblock %} + +{% block main %} +
+
+ +
+
+ +
+ +
+{% endblock %} diff --git a/templates/quote.html b/templates/quote.html new file mode 100644 index 0000000..aebde0e --- /dev/null +++ b/templates/quote.html @@ -0,0 +1,23 @@ +{% extends "layout.html" %} + +{% block title %} + Quote +{% endblock %} + +{% block main %} + + {% if stock %} +

A share of {{ stock.symbol }} costs {{ stock.price }}

+ + {% else%} +
+
+ +
+ +
+ {% endif %} + + + +{% endblock %} diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..c0bddda --- /dev/null +++ b/templates/register.html @@ -0,0 +1,20 @@ +{% extends "layout.html" %} + +{% block title %} + Register +{% endblock %} + +{% block main %} +
+
+ +
+
+ +
+
+ +
+ +
+{% endblock %} diff --git a/templates/sell.html b/templates/sell.html new file mode 100644 index 0000000..68f5186 --- /dev/null +++ b/templates/sell.html @@ -0,0 +1,22 @@ +{% extends "layout.html" %} + +{% block title %} + Sell +{% endblock %} + +{% block main %} +
+
+ +
+
+ +
+ +
+{% endblock %}