From cc6e0efab8ceeac46e14f11edd47d3c335464930 Mon Sep 17 00:00:00 2001 From: Wojciech Kwolek Date: Sat, 26 Sep 2020 16:32:37 +0000 Subject: [PATCH] Implement timezones and start of day. (#11) add a script to pre-fill the timezone on the registration page add created_on, copy values from accomplishment.time, normalize accomplishment.time make the start_of_day and timezone migration work on pre-existing databases Switched the code to use the Day class everywhere. * Removed timeutils.py. * Added from_str method to the Day class. * Implemented __add__ and __sub__ operators for the Day class. You can now get the next day by writing `day + 1` (or any other integer.) * Fixed Day.today: it used to take the "self" argument, despite being a static method. * Made the settings view use pytz.all_timezones instead of pytz.common_timezones, to make sure everyone's included. Made Day class aware of the user's settings. It now takes the start-of-day hour and the timezone from the user's profile. Moreover, equality and comparison operators were implemented, as well as __repr__. Implemented is_today and is_future. implement saving the settings form Added settings templates + cosmetics * Refactored render_field to support SelectFields to avoid code duplication * Introduced the /settings route, the logic for which the logic is not yet implemented * Fixed missing > in one of the favicon s in * Added a Home and Settings link to the navigation area in the header * Made the card width consistent (login/logout/register used to be less wide than the other views) * Centered the "Are you sure you want to log out?" text added timezone selector to registration basic implementation for getting the current day for the user initial implementation of the new Day class Co-authored-by: Wojciech Kwolek Reviewed-on: https://git.r23s.eu/wojciech/doneth.at-backend/pulls/11 --- Pipfile | 4 +- Pipfile.lock | 102 +++++++++++------- app/__init__.py | 3 + app/auth.py | 14 ++- app/css/main.css | 10 +- app/days.py | 92 ++++++++++++++++ app/db.py | 60 ++++++----- app/graph.py | 13 +-- app/main.py | 66 ++++++------ app/settings.py | 43 ++++++++ app/templates/_formhelpers.html | 15 ++- app/templates/_skel.html | 9 +- app/templates/auth/login.html | 5 +- app/templates/auth/logout.html | 8 +- app/templates/auth/register.html | 18 +++- app/templates/main/app.html | 12 +-- app/templates/main/edit.html | 6 +- app/templates/settings.html | 16 +++ app/timeutils.py | 54 ---------- migrations/env.py | 3 +- ...2_add_timezone_and_start_of_day_to_user.py | 39 +++++++ ...684a50_add_created_on_to_accomplishment.py | 53 +++++++++ 22 files changed, 456 insertions(+), 189 deletions(-) create mode 100644 app/days.py create mode 100644 app/settings.py create mode 100644 app/templates/settings.html delete mode 100644 app/timeutils.py create mode 100644 migrations/versions/517c61e085a2_add_timezone_and_start_of_day_to_user.py create mode 100644 migrations/versions/687170684a50_add_created_on_to_accomplishment.py 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 ###