diff --git a/Pipfile b/Pipfile index 1173149..03b74b2 100644 --- a/Pipfile +++ b/Pipfile @@ -14,6 +14,4 @@ flask-bcrypt = "*" flask-migrate = "*" flask-wtf = "*" flask-static-digest = "*" - -[requires] -python_version = "3.7" +pytz = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 0d3d34a..c8b3d62 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,12 +1,10 @@ { "_meta": { "hash": { - "sha256": "c7dae1884dadc4003c9a58e8fb090dbae9a60d5308a065644eb80b3ea54cb45a" + "sha256": "5df2ce53af8895efe513915c12c6cc3b4b86b8386b571ccdd9358a06b75c4811" }, "pipfile-spec": 6, - "requires": { - "python_version": "3.7" - }, + "requires": {}, "sources": [ { "name": "pypi", @@ -18,9 +16,11 @@ "default": { "alembic": { "hashes": [ - "sha256:035ab00497217628bf5d0be82d664d8713ab13d37b630084da8e1f98facf4dbf" + "sha256:4e02ed2aa796bd179965041afa092c55b51fb077de19d61835673cc80672c01c", + "sha256:5334f32314fb2a56d86b4c4dd1ae34b08c03cae4cb888bc699942104d66bc245" ], - "version": "==1.4.2" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.4.3" }, "bcrypt": { "hashes": [ @@ -32,46 +32,56 @@ "sha256:cd1ea2ff3038509ea95f687256c46b79f5fc382ad0aa3664d200047546d511d1", "sha256:cdcdcb3972027f83fe24a48b1e90ea4b584d35f1cc279d76de6fc4b13376239d" ], + "markers": "python_version >= '3.6'", "version": "==3.2.0" }, "cffi": { "hashes": [ - "sha256:0da50dcbccd7cb7e6c741ab7912b2eff48e85af217d72b57f80ebc616257125e", - "sha256:12a453e03124069b6896107ee133ae3ab04c624bb10683e1ed1c1663df17c13c", - "sha256:15419020b0e812b40d96ec9d369b2bc8109cc3295eac6e013d3261343580cc7e", - "sha256:15a5f59a4808f82d8ec7364cbace851df591c2d43bc76bcbe5c4543a7ddd1bf1", - "sha256:23e44937d7695c27c66a54d793dd4b45889a81b35c0751ba91040fe825ec59c4", - "sha256:29c4688ace466a365b85a51dcc5e3c853c1d283f293dfcc12f7a77e498f160d2", - "sha256:57214fa5430399dffd54f4be37b56fe22cedb2b98862550d43cc085fb698dc2c", - "sha256:577791f948d34d569acb2d1add5831731c59d5a0c50a6d9f629ae1cefd9ca4a0", - "sha256:6539314d84c4d36f28d73adc1b45e9f4ee2a89cdc7e5d2b0a6dbacba31906798", - "sha256:65867d63f0fd1b500fa343d7798fa64e9e681b594e0a07dc934c13e76ee28fb1", - "sha256:672b539db20fef6b03d6f7a14b5825d57c98e4026401fce838849f8de73fe4d4", - "sha256:6843db0343e12e3f52cc58430ad559d850a53684f5b352540ca3f1bc56df0731", - "sha256:7057613efefd36cacabbdbcef010e0a9c20a88fc07eb3e616019ea1692fa5df4", - "sha256:76ada88d62eb24de7051c5157a1a78fd853cca9b91c0713c2e973e4196271d0c", - "sha256:837398c2ec00228679513802e3744d1e8e3cb1204aa6ad408b6aff081e99a487", - "sha256:8662aabfeab00cea149a3d1c2999b0731e70c6b5bac596d95d13f643e76d3d4e", - "sha256:95e9094162fa712f18b4f60896e34b621df99147c2cee216cfa8f022294e8e9f", - "sha256:99cc66b33c418cd579c0f03b77b94263c305c389cb0c6972dac420f24b3bf123", - "sha256:9b219511d8b64d3fa14261963933be34028ea0e57455baf6781fe399c2c3206c", - "sha256:ae8f34d50af2c2154035984b8b5fc5d9ed63f32fe615646ab435b05b132ca91b", - "sha256:b9aa9d8818c2e917fa2c105ad538e222a5bce59777133840b93134022a7ce650", - "sha256:bf44a9a0141a082e89c90e8d785b212a872db793a0080c20f6ae6e2a0ebf82ad", - "sha256:c0b48b98d79cf795b0916c57bebbc6d16bb43b9fc9b8c9f57f4cf05881904c75", - "sha256:da9d3c506f43e220336433dffe643fbfa40096d408cb9b7f2477892f369d5f82", - "sha256:e4082d832e36e7f9b2278bc774886ca8207346b99f278e54c9de4834f17232f7", - "sha256:e4b9b7af398c32e408c00eb4e0d33ced2f9121fd9fb978e6c1b57edd014a7d15", - "sha256:e613514a82539fc48291d01933951a13ae93b6b444a88782480be32245ed4afa", - "sha256:f5033952def24172e60493b68717792e3aebb387a8d186c43c020d9363ee7281" + "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d", + "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b", + "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4", + "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f", + "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3", + "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579", + "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537", + "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e", + "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05", + "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171", + "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca", + "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522", + "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c", + "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc", + "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d", + "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808", + "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828", + "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869", + "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d", + "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9", + "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0", + "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc", + "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15", + "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c", + "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a", + "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3", + "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1", + "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768", + "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d", + "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b", + "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e", + "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d", + "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730", + "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394", + "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1", + "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591" ], - "version": "==1.14.2" + "version": "==1.14.3" }, "click": { "hashes": [ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "flask": { @@ -133,6 +143,7 @@ "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.0" }, "jinja2": { @@ -140,6 +151,7 @@ "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.2" }, "mako": { @@ -147,6 +159,7 @@ "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27", "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.3" }, "markupsafe": { @@ -185,6 +198,7 @@ "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "pycparser": { @@ -192,6 +206,7 @@ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.20" }, "python-dateutil": { @@ -199,21 +214,33 @@ "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.1" }, "python-editor": { "hashes": [ "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", - "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8" + "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8", + "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77", + "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522" ], "version": "==1.0.4" }, + "pytz": { + "hashes": [ + "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", + "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" + ], + "index": "pypi", + "version": "==2020.1" + }, "six": { "hashes": [ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "sqlalchemy": { @@ -251,6 +278,7 @@ "sha256:f2e8a9c0c8813a468aa659a01af6592f71cd30237ec27c4cc0683f089f90dcfc", "sha256:fe7fe11019fc3e6600819775a7d55abc5446dda07e9795f5954fdbf8a49e1c37" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.19" }, "werkzeug": { @@ -258,6 +286,7 @@ "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==1.0.1" }, "wtforms": { @@ -281,6 +310,7 @@ "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.6.0" }, "toml": { diff --git a/app/__init__.py b/app/__init__.py index 3c802ba..6b3abdf 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -31,4 +31,7 @@ def create_app(): from . import graph app.register_blueprint(graph.blueprint) + from . import settings + app.register_blueprint(settings.blueprint) + return app diff --git a/app/auth.py b/app/auth.py index a835d08..c162299 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,11 +1,13 @@ from flask import Blueprint, render_template, redirect from flask_wtf import FlaskForm -from wtforms import StringField, PasswordField, SubmitField +from wtforms import StringField, PasswordField, SubmitField, SelectField from wtforms.validators import DataRequired, Email, EqualTo, Length, Optional from flask_login import LoginManager, login_user, logout_user import sqlalchemy.exc from .db import db, User +import pytz + blueprint = Blueprint('auth', __name__) login_manager = LoginManager() @@ -24,6 +26,12 @@ class SignupForm(FlaskForm): 'Username', validators=[DataRequired(), Length(min=2), Length(max=64)] ) + + tz = SelectField('Timezone', choices=list(map(lambda x: (x, x.replace("_", " ")), pytz.all_timezones)), + validators=[ + DataRequired() + ]) + password = PasswordField( 'Password', validators=[ @@ -32,6 +40,7 @@ class SignupForm(FlaskForm): DataRequired() ] ) + confirm = PasswordField( 'Confirm password', validators=[ @@ -39,6 +48,7 @@ class SignupForm(FlaskForm): EqualTo('password', message='Passwords do not match') ] ) + submit = SubmitField('Register') @@ -64,6 +74,8 @@ def register(): if form.validate_on_submit(): user = User(username=form.username.data) user.set_password(form.password.data) + user.timezone = form.tz.data + user.start_of_day = 2 try: db.session.add(user) db.session.commit() diff --git a/app/css/main.css b/app/css/main.css index bd26d47..7d8f99c 100644 --- a/app/css/main.css +++ b/app/css/main.css @@ -9,7 +9,9 @@ body { } form input[type=text], -form input[type=password] { +form input[type=password], +form input[type=number], +form select { @apply shadow; @apply appearance-none; @apply border; @@ -21,6 +23,12 @@ form input[type=password] { @apply leading-tight; } +form input:focus, +form select:focus { + @apply outline-none; + @apply shadow-outline; +} + form div.error input { @apply border-red-500; } diff --git a/app/days.py b/app/days.py new file mode 100644 index 0000000..bbbdef2 --- /dev/null +++ b/app/days.py @@ -0,0 +1,92 @@ +from datetime import datetime, timedelta, timezone +import pytz + + +def _suffix(d): + return 'th' if 11 <= d <= 13 else {1: 'st', 2: 'nd', 3: 'rd'}.get(d % 10, 'th') + + +class Day: + def __init__(self, year, month, day, user): + self.user = user + self._timestamp = datetime(year, month, day, tzinfo=timezone.utc) + + @staticmethod + def from_str(string, user): + return Day._from_timestamp(datetime.strptime(string, "%Y-%m-%d"), user) + + @staticmethod + def _from_timestamp(timestamp, user): + # not exposed because it is only an utilty function, not aware of the + # user's time zone and start-of-day hour + return Day(timestamp.year, timestamp.month, timestamp.day, user) + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + return self.year == other.year \ + and self.month == other.month \ + and self.day == other.day + + def __add__(self, other, oper="+"): + if not isinstance(other, int): + raise TypeError( + 'Unsupported operands for "+". The right hand side needs to be a number.') + else: + return Day._from_timestamp(self.timestamp + timedelta(days=other), self.user) + + def __sub__(self, other): + if not isinstance(other, int): + raise TypeError( + 'Unsupported operands for "-". The right hand side needs to be a number.') + else: + return Day._from_timestamp(self.timestamp + timedelta(days=-other), self.user) + + def __lt__(self, other): return self.timestamp < other.timestamp + def __gt__(self, other): return self.timestamp > other.timestamp + def __le__(self, other): return self < other or self == other + def __ge__(self, other): return self > other or self == other + + def __repr__(self): return "" % (self.url, self.user) + + @property + def year(self): return self._timestamp.year + + @property + def month(self): return self._timestamp.month + + @property + def day(self): return self._timestamp.day + + @property + def timestamp(self): return self._timestamp + + @property + def is_today(self): + return self == Day.today(self.user) + + @property + def is_future(self): + return self > Day.today(self.user) + + @property + def pretty(self): + if self.is_today: + return "Today" + return self._timestamp.strftime("%B {S}, %Y").replace('{S}', str(self.day) + _suffix(self.day)) + + @property + def url(self): + return self._timestamp.strftime("%Y-%m-%d") + + @staticmethod + def today(user): + tz = pytz.timezone(user.timezone) + + now = datetime.now(tz) + day = Day(now.year, now.month, now.day, user) + + if now.hour < user.start_of_day: + day -= 1 + + return day diff --git a/app/db.py b/app/db.py index 5884277..e7c2a21 100644 --- a/app/db.py +++ b/app/db.py @@ -5,8 +5,7 @@ from flask_login import UserMixin from flask_bcrypt import generate_password_hash, check_password_hash from flask_migrate import Migrate from datetime import datetime, timedelta - -from . import timeutils +from .days import Day db = SQLAlchemy() migrate = Migrate() @@ -21,14 +20,18 @@ class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(128), unique=True, nullable=False) password = db.Column(db.String(128), nullable=False) - created_on = db.Column(db.DateTime, index=False, - unique=False, nullable=True) - last_login = db.Column(db.DateTime, index=False, - unique=False, nullable=True) + created_on = db.Column(db.DateTime, index=False, unique=False, + nullable=True, server_default=db.func.now()) + last_login = db.Column(db.DateTime, index=False, unique=False, + nullable=True) # TODO: set on login? or remove? accomplishments = db.relationship( 'Accomplishment', backref='user', lazy=True) + # TODO: set user timezone from geoip on registration + timezone = db.Column(db.String(64), nullable=False) + start_of_day = db.Column(db.Integer, nullable=False) + def set_password(self, password): self.password = generate_password_hash(password) @@ -42,8 +45,9 @@ class User(UserMixin, db.Model): class Accomplishment(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) - time = db.Column(db.DateTime(), nullable=False, - default=db.func.current_timestamp()) + created_on = db.Column(db.DateTime, index=False, unique=False, + nullable=True, server_default=db.func.now()) + time = db.Column(db.DateTime(), nullable=False) text = db.Column(db.String(256), nullable=False) difficulty = db.Column(db.Integer) @@ -60,33 +64,35 @@ class Accomplishment(db.Model): return "hard" @staticmethod - def get_time_range(user_id, start, end): + def get_time_range(user, start: datetime, end: datetime): return Accomplishment.query.filter( - Accomplishment.time >= start, Accomplishment.time < end, Accomplishment.user_id == user_id).all() + Accomplishment.time >= start, Accomplishment.time < end, Accomplishment.user_id == user.id).all() - def get_time_range_total(user_id, start, end): + @staticmethod + def get_time_range_total(user, start: datetime, end: datetime): result = db.session.query(func.sum(Accomplishment.difficulty).label('total')).filter( - Accomplishment.time >= start, Accomplishment.time < end, Accomplishment.user_id == user_id)[0][0] + Accomplishment.time >= start, Accomplishment.time < end, Accomplishment.user_id == user.id)[0][0] return result if result is not None else 0 @staticmethod - def get_day(user_id, day): + def get_day(user, day: Day): # TODO: allow setting custom "start of day" hour - start = timeutils.day(day) - end = timeutils.day_after(day) - return Accomplishment.get_time_range(user_id, start, end) - - def get_day_total(user_id, day): - start = timeutils.day(day) - end = timeutils.day_after(day) - return Accomplishment.get_time_range_total(user_id, start, end) + start = day.timestamp + end = (day + 1).timestamp + return Accomplishment.get_time_range(user, start, end) @staticmethod - def get_today(user_id): - today = datetime.now() - return Accomplishment.get_day(user_id, today) + def get_day_total(user, day: Day): + start = day.timestamp + end = (day + 1).timestamp + return Accomplishment.get_time_range_total(user, start, end) @staticmethod - def get_today_total(user_id): - today = datetime.now() - return Accomplishment.get_day_total(user_id, today) + def get_today(user): + today = Day.today(user) + return Accomplishment.get_day(user, today) + + @staticmethod + def get_today_total(user): + today = Day.today(user) + return Accomplishment.get_day_total(user, today) diff --git a/app/graph.py b/app/graph.py index fe605b4..4747041 100644 --- a/app/graph.py +++ b/app/graph.py @@ -1,8 +1,7 @@ from flask import Blueprint, render_template from flask_login import login_required, current_user from .db import db, Accomplishment -from . import timeutils - +from .days import Day blueprint = Blueprint('graph', __name__) @@ -13,15 +12,13 @@ def graph_svg(): count = 7 accomplishments = [0]*count days = [""]*count - day = timeutils.today() + day = Day.today(current_user) for i in range(1, count+1): - total_xp = Accomplishment.get_day_total(current_user.id, day) + total_xp = Accomplishment.get_day_total(current_user, day) accomplishments[-i] = total_xp - days[-i] = day.strftime('%a')[:2] - day = timeutils.day_before(day) - - print(accomplishments) + days[-i] = day.timestamp.strftime('%a')[:2] + day -= 1 return render_template('graph.svg', days=days, **gen_graph_data(accomplishments)), 200, {'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-cache'} diff --git a/app/main.py b/app/main.py index c1d3179..ef987cb 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,3 @@ -from . import timeutils from flask import Blueprint, render_template, redirect, url_for, abort, request from flask_login import current_user, login_required from flask_wtf import FlaskForm @@ -7,6 +6,7 @@ from wtforms.validators import DataRequired, Length, NumberRange from .db import db, Accomplishment from datetime import datetime, timedelta import time +from .days import Day main = Blueprint('main', __name__) @@ -30,46 +30,39 @@ def handle_accomplishment_submission(form): accomplishment.difficulty = 10 elif form.submit_15.data: accomplishment.difficulty = 15 - # the timestamp should be set by the database + accomplishment.time = Day.today(current_user).timestamp db.session.add(accomplishment) db.session.commit() return redirect(url_for('main.index')) -def parse_day(day_string): - day_datetime = None +def parse_day(day_string, user): + day = None if day_string == "today": - day_datetime = timeutils.today() + day = Day.today(user) else: - day_datetime = timeutils.from_str(day_string) + day = Day.from_str(day_string, user) - day_string_clean = timeutils.as_str(day_datetime) - return { - "datetime": day_datetime, - "string": day_string_clean, - "fancy": timeutils.as_fancy_str(day_datetime), - "is_today": timeutils.is_today(day_datetime) - } + return day -def get_day_template_data(day_string): - day = parse_day(day_string) - day_datetime = day["datetime"] +def get_day_template_data(day_string, user): + day = parse_day(day_string, user) accomplishments = list(reversed( - Accomplishment.get_day(current_user.id, day_datetime))) + Accomplishment.get_day(current_user, day))) total = sum(a.difficulty for a in accomplishments) - yesterday = timeutils.day_before(day_datetime) - tomorrow = timeutils.day_after(day_datetime) - if timeutils.is_future(tomorrow): + yesterday = day - 1 + tomorrow = day + 1 + if tomorrow.is_future: tomorrow = None return { "day": day, "links": { - "yesterday": url_for('main.index', day=timeutils.as_str(yesterday)), - "tomorrow": url_for('main.index', day=timeutils.as_str(tomorrow)) if tomorrow is not None else None + "yesterday": url_for('main.index', day=yesterday.url), + "tomorrow": url_for('main.index', day=tomorrow.url) if tomorrow is not None else None }, "accomplishments": accomplishments, "total_xp": sum(a.difficulty for a in accomplishments), @@ -81,6 +74,7 @@ def get_day_template_data(day_string): @main.route('/day/') def index(day): if not current_user.is_authenticated: + # TODO: handle the case when the user is on /day/ and is not logged in return render_template('index.html') form = NewAccomplishementForm() @@ -90,7 +84,7 @@ def index(day): return render_template( 'main/app.html', form=form, - **get_day_template_data(day) + **get_day_template_data(day, current_user) ) @@ -105,7 +99,7 @@ def edit_day(day): 'main/app.html', form=form, edit=True, - **get_day_template_data(day) + **get_day_template_data(day, current_user) ) @@ -120,8 +114,8 @@ def delete_accomplishment(accomplishment_id): if a.user_id != current_user.id: abort(403) - back_url = url_for( - 'main.edit_day', day=timeutils.as_str(timeutils.day(a.time))) + # TODO: fix: we're using from_str when it's a datetime in the db? it works on sqlite but + back_url = url_for('main.edit_day', day=Day.from_str(a.time, user).url) form = DeleteForm() if form.validate_on_submit(): @@ -155,8 +149,8 @@ def edit_accomplishment(accomplishment_id): if a.user_id != current_user.id: abort(403) - back_url = url_for( - 'main.edit_day', day=timeutils.as_str(timeutils.day(a.time))) + back_url = url_for('main.edit_day', day=Day.from_str( + a.time, current_user).url) form = EditForm(obj=a) if form.validate_on_submit(): @@ -171,28 +165,34 @@ def edit_accomplishment(accomplishment_id): @main.route('/day//add', methods=['GET', 'POST']) @login_required def add_day(day): - day_parsed = parse_day(day) + day = parse_day(day, current_user) form = EditForm() back_url = "" from_top = ("from" in request.args) and ("top" in request.args["from"]) - back_to_day = url_for('main.index', day=day_parsed["string"]) - back_to_edit = url_for('main.edit_day', day=day_parsed["string"]) + # to the bottom + # bottom to top I stop + # at the core I've forgotten + # in the middle of my thoughts + # taken far from my safety + # the picture is there + back_to_day = url_for('main.index', day=day.url) + back_to_edit = url_for('main.edit_day', day=day.url) if form.validate_on_submit(): accomplishment = Accomplishment() accomplishment.user_id = current_user.id accomplishment.text = form.text.data accomplishment.difficulty = form.difficulty.data - accomplishment.time = timeutils.from_str(day) + accomplishment.time = day.timestamp db.session.add(accomplishment) db.session.commit() return redirect(back_to_day) return render_template( 'main/edit.html', - day=day_parsed, + day=day, form=form, edit=True, cancel=back_to_day if from_top else back_to_edit diff --git a/app/settings.py b/app/settings.py new file mode 100644 index 0000000..ad24b62 --- /dev/null +++ b/app/settings.py @@ -0,0 +1,43 @@ +from flask import Blueprint, render_template +from flask_login import current_user, login_required +from flask_wtf import FlaskForm +from .db import db +from wtforms import SelectField, SubmitField +from wtforms.fields.html5 import IntegerField +from wtforms.widgets.html5 import NumberInput +from wtforms.validators import DataRequired, NumberRange +import pytz + +blueprint = Blueprint('settings', __name__) + + +class SettingsForm(FlaskForm): + timezone = SelectField( + 'Timezone', choices=list(map(lambda x: (x, x.replace("_", " ")), pytz.all_timezones)), + validators=[ + DataRequired() + ]) + + start_of_day = IntegerField( + 'Start of day hour', + widget=NumberInput(min=0, max=23), + validators=[ + DataRequired(), + NumberRange(min=0, max=23) + ] + ) + + submit = SubmitField('Save') + + +@blueprint.route('/settings', methods=['GET', 'POST']) +@login_required +def settings(): + form = SettingsForm(obj=current_user) + if form.validate_on_submit(): + current_user.timezone = form.timezone.data + current_user.start_of_day = form.start_of_day.data + db.session.commit() + return render_template('settings.html', form=form, success=True) + + return render_template('settings.html', form=form) diff --git a/app/templates/_formhelpers.html b/app/templates/_formhelpers.html index a96822f..852fe44 100644 --- a/app/templates/_formhelpers.html +++ b/app/templates/_formhelpers.html @@ -1,8 +1,19 @@ -{% macro render_field(field, label=True, wrapper_class="") %} +{% macro render_field(field, label=True, wrapper_class="", description="") %}
+ class="block text-sm font-bold text-gray-700">{% if label %}{{ field.label }}{% endif %} +
{{ description }}
+ {% if field.type == "SelectField" %} +
+ {{ field(**kwargs)|safe }} +
+ + +
+
+ {% else %} {{ field(**kwargs)|safe }} + {% endif %} {% if field.errors %}
    {% for error in field.errors %} diff --git a/app/templates/_skel.html b/app/templates/_skel.html index 4f99d27..8780337 100644 --- a/app/templates/_skel.html +++ b/app/templates/_skel.html @@ -31,8 +31,8 @@ sizes="196x196"> - + {% endblock %} @@ -47,7 +47,10 @@ {% if current_user.is_authenticated %}

    Hi {{ current_user.username }}!

    - Log out. + Home + Settings + Log + out

    {% endif %} {% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index 7e6d96b..ceeb5f1 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -1,10 +1,9 @@ {% extends "_skel.html" %} {% block title %}Log in{% endblock %} -{% block header_width %}max-w-xs{% endblock %} {% from "_formhelpers.html" import render_field %} {% block content %} -
    -
    +
    + {{ form.csrf_token }} {{ render_field(form.username) }} {{ render_field(form.password) }} diff --git a/app/templates/auth/logout.html b/app/templates/auth/logout.html index 354d44f..d62cead 100644 --- a/app/templates/auth/logout.html +++ b/app/templates/auth/logout.html @@ -1,12 +1,10 @@ {% extends "_skel.html" %} {% block title %}Log out{% endblock %} -{% block header_width %}max-w-xs{% endblock %} -{% block header_user %}{% endblock %} {% from "_formhelpers.html" import render_field %} {% block content %} -
    - -

    Are you sure you want to log out?

    +
    + +

    Are you sure you want to log out?

    {{ form.csrf_token }} {{ render_field(form.submit, False) }}

    diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html index e1732f6..dff6318 100644 --- a/app/templates/auth/register.html +++ b/app/templates/auth/register.html @@ -1,15 +1,27 @@ {% extends "_skel.html" %} {% block title %}Register{% endblock %} -{% block header_width %}max-w-xs{% endblock %} {% from "_formhelpers.html" import render_field %} {% block content %} -

    - +
    + {{ form.csrf_token }} {{ render_field(form.username) }} + {{ render_field(form.tz) }} {{ render_field(form.password) }} {{ render_field(form.confirm) }} {{ render_field(form.submit, False) }}
    + + + {% endblock %} diff --git a/app/templates/main/app.html b/app/templates/main/app.html index 6a14445..c9f76da 100644 --- a/app/templates/main/app.html +++ b/app/templates/main/app.html @@ -1,5 +1,5 @@ {% extends "_skel.html" %} -{% block title %}{{ day.fancy }}{% endblock %} +{% block title %}{{ day.pretty }}{% endblock %} {% block content %}
    @@ -18,23 +18,23 @@
    -

    {{ day.fancy }}

    +

    {{ day.pretty }}

    {% if edit %} - + {% else %} {% if accomplishments %} - + {% else %} {% if not day.is_today %} - + {% endif %} {% endif %} {% endif %}
    {% if edit %}
    {% endif %} diff --git a/app/templates/main/edit.html b/app/templates/main/edit.html index 505404f..be56967 100644 --- a/app/templates/main/edit.html +++ b/app/templates/main/edit.html @@ -5,12 +5,12 @@ {% if day %}
    - You're adding an accomplishment made on {{ day.fancy }}. + You're adding an accomplishment made on {{ day.pretty }}.
    {% endif %} - + {{ form.csrf_token }} {{ render_field(form.text) }} {{ render_field(form.difficulty) }} diff --git a/app/templates/settings.html b/app/templates/settings.html new file mode 100644 index 0000000..7898393 --- /dev/null +++ b/app/templates/settings.html @@ -0,0 +1,16 @@ +{% extends "_skel.html" %} +{% block title %}Settings{% endblock %} +{% from "_formhelpers.html" import render_field %} +{% block content %} +
    + + + {{ form.csrf_token }} + {{ render_field(form.timezone) }} + {{ render_field(form.start_of_day, description="This is useful if you often work after midnight. If you set it to e.g. 2, all accomplishments made before 2 AM will be considered to be made on the previous day.") }} + {{ render_field(form.submit, False) }} + {% if success %}

    Saved successfuly.

    {% endif %} + +
    +{% endblock %} + diff --git a/app/timeutils.py b/app/timeutils.py deleted file mode 100644 index d16a7a4..0000000 --- a/app/timeutils.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -The timeutils module is supposed to be where ALL time related logic goes. -This is meant to ease handling timezones and custom day-start-hours later. -""" - -from datetime import datetime, timedelta - -# TODO: make it all custom-day-start-hour aware - - -def from_str(string): - return datetime.strptime(string, "%Y-%m-%d") - - -def as_str(day_): - if day_ is None: - return None - return day(day_).strftime("%Y-%m-%d") - - -def _suffix(d): - return 'th' if 11 <= d <= 13 else {1: 'st', 2: 'nd', 3: 'rd'}.get(d % 10, 'th') - - -def as_fancy_str(day_): - if day_ is None: - return None - if is_today(day_): - return "Today" - return day_.strftime("%B {S}, %Y").replace('{S}', str(day_.day) + _suffix(day_.day)) - - -def day(timestamp): - return datetime(timestamp.year, timestamp.month, timestamp.day) - - -def today(): - return day(datetime.now()) - - -def day_after(day_): - return day(day_) + timedelta(days=1) - - -def day_before(day_): - return day(day_) - timedelta(days=1) - - -def is_future(day_): - return day(day_) > today() - - -def is_today(day_): - return day(day_) == today() diff --git a/migrations/env.py b/migrations/env.py index 9452179..14c3c3f 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -1,4 +1,5 @@ from __future__ import with_statement +from flask import current_app import logging from logging.config import fileConfig @@ -21,7 +22,6 @@ logger = logging.getLogger('alembic.env') # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata -from flask import current_app config.set_main_option( 'sqlalchemy.url', str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%')) @@ -83,6 +83,7 @@ def run_migrations_online(): connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, + render_as_batch=True, **current_app.extensions['migrate'].configure_args ) diff --git a/migrations/versions/517c61e085a2_add_timezone_and_start_of_day_to_user.py b/migrations/versions/517c61e085a2_add_timezone_and_start_of_day_to_user.py new file mode 100644 index 0000000..274170a --- /dev/null +++ b/migrations/versions/517c61e085a2_add_timezone_and_start_of_day_to_user.py @@ -0,0 +1,39 @@ +"""add timezone and start-of-day to user + +Revision ID: 517c61e085a2 +Revises: cd3b1ad8c50b +Create Date: 2020-09-25 00:16:53.532597 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '517c61e085a2' +down_revision = 'cd3b1ad8c50b' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('user', sa.Column( + 'start_of_day', sa.Integer(), nullable=True)) + op.add_column('user', sa.Column( + 'timezone', sa.String(length=64), nullable=True)) + sa.orm.Session(bind=op.get_bind()).commit() + + # batch mode is needed to support sqlite, which doesn't support ALTER COLUMN + with op.batch_alter_table('user') as batch_op: + batch_op.execute("UPDATE user SET start_of_day = 2") + batch_op.execute("UPDATE user SET timezone = 'Europe/Warsaw'") + + batch_op.alter_column('start_of_day', nullable=False) + batch_op.alter_column('timezone', nullable=False) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'timezone') + op.drop_column('user', 'start_of_day') + # ### end Alembic commands ### diff --git a/migrations/versions/687170684a50_add_created_on_to_accomplishment.py b/migrations/versions/687170684a50_add_created_on_to_accomplishment.py new file mode 100644 index 0000000..b111066 --- /dev/null +++ b/migrations/versions/687170684a50_add_created_on_to_accomplishment.py @@ -0,0 +1,53 @@ +"""Add created_on to Accomplishment + +Revision ID: 687170684a50 +Revises: 517c61e085a2 +Create Date: 2020-09-26 17:43:11.471553 + +""" +from alembic import op +import sqlalchemy as sa +from datetime import datetime, timezone + + +# revision identifiers, used by Alembic. +revision = '687170684a50' +down_revision = '517c61e085a2' +branch_labels = None +depends_on = None + +accomphelper = sa.Table( + 'accomplishment', + sa.MetaData(), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('time', sa.DateTime(), nullable=False), + sa.Column('created_on', sa.DateTime(), nullable=True), +) + + +def upgrade(): + with op.batch_alter_table('accomplishment', recreate='always') as batch_op: + batch_op.add_column( + sa.Column( + 'created_on', sa.DateTime(), + server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True + )) + + connection = op.get_bind() + for accomplishment in connection.execute(accomphelper.select()): + t = accomplishment.time + connection.execute( + accomphelper.update().where( + accomphelper.c.id == accomplishment.id + ).values( + created_on=accomplishment.time, + time=datetime(t.year, t.month, t.day, tzinfo=timezone.utc) + ) + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('accomplishment', 'created_on') + # ### end Alembic commands ###