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 <link>s in <head>
* 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 <wojciech@kwolek.xyz>
Reviewed-on: https://git.r23s.eu/wojciech/doneth.at-backend/pulls/11
This commit is contained in:
Wojciech Kwolek 2020-09-26 16:32:37 +00:00
parent a899d1c1be
commit cc6e0efab8
22 changed files with 456 additions and 189 deletions

View File

@ -14,6 +14,4 @@ flask-bcrypt = "*"
flask-migrate = "*" flask-migrate = "*"
flask-wtf = "*" flask-wtf = "*"
flask-static-digest = "*" flask-static-digest = "*"
pytz = "*"
[requires]
python_version = "3.7"

102
Pipfile.lock generated
View File

@ -1,12 +1,10 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "c7dae1884dadc4003c9a58e8fb090dbae9a60d5308a065644eb80b3ea54cb45a" "sha256": "5df2ce53af8895efe513915c12c6cc3b4b86b8386b571ccdd9358a06b75c4811"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {},
"python_version": "3.7"
},
"sources": [ "sources": [
{ {
"name": "pypi", "name": "pypi",
@ -18,9 +16,11 @@
"default": { "default": {
"alembic": { "alembic": {
"hashes": [ "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": { "bcrypt": {
"hashes": [ "hashes": [
@ -32,46 +32,56 @@
"sha256:cd1ea2ff3038509ea95f687256c46b79f5fc382ad0aa3664d200047546d511d1", "sha256:cd1ea2ff3038509ea95f687256c46b79f5fc382ad0aa3664d200047546d511d1",
"sha256:cdcdcb3972027f83fe24a48b1e90ea4b584d35f1cc279d76de6fc4b13376239d" "sha256:cdcdcb3972027f83fe24a48b1e90ea4b584d35f1cc279d76de6fc4b13376239d"
], ],
"markers": "python_version >= '3.6'",
"version": "==3.2.0" "version": "==3.2.0"
}, },
"cffi": { "cffi": {
"hashes": [ "hashes": [
"sha256:0da50dcbccd7cb7e6c741ab7912b2eff48e85af217d72b57f80ebc616257125e", "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d",
"sha256:12a453e03124069b6896107ee133ae3ab04c624bb10683e1ed1c1663df17c13c", "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b",
"sha256:15419020b0e812b40d96ec9d369b2bc8109cc3295eac6e013d3261343580cc7e", "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4",
"sha256:15a5f59a4808f82d8ec7364cbace851df591c2d43bc76bcbe5c4543a7ddd1bf1", "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f",
"sha256:23e44937d7695c27c66a54d793dd4b45889a81b35c0751ba91040fe825ec59c4", "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3",
"sha256:29c4688ace466a365b85a51dcc5e3c853c1d283f293dfcc12f7a77e498f160d2", "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579",
"sha256:57214fa5430399dffd54f4be37b56fe22cedb2b98862550d43cc085fb698dc2c", "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537",
"sha256:577791f948d34d569acb2d1add5831731c59d5a0c50a6d9f629ae1cefd9ca4a0", "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e",
"sha256:6539314d84c4d36f28d73adc1b45e9f4ee2a89cdc7e5d2b0a6dbacba31906798", "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05",
"sha256:65867d63f0fd1b500fa343d7798fa64e9e681b594e0a07dc934c13e76ee28fb1", "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171",
"sha256:672b539db20fef6b03d6f7a14b5825d57c98e4026401fce838849f8de73fe4d4", "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca",
"sha256:6843db0343e12e3f52cc58430ad559d850a53684f5b352540ca3f1bc56df0731", "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522",
"sha256:7057613efefd36cacabbdbcef010e0a9c20a88fc07eb3e616019ea1692fa5df4", "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c",
"sha256:76ada88d62eb24de7051c5157a1a78fd853cca9b91c0713c2e973e4196271d0c", "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc",
"sha256:837398c2ec00228679513802e3744d1e8e3cb1204aa6ad408b6aff081e99a487", "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d",
"sha256:8662aabfeab00cea149a3d1c2999b0731e70c6b5bac596d95d13f643e76d3d4e", "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808",
"sha256:95e9094162fa712f18b4f60896e34b621df99147c2cee216cfa8f022294e8e9f", "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828",
"sha256:99cc66b33c418cd579c0f03b77b94263c305c389cb0c6972dac420f24b3bf123", "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869",
"sha256:9b219511d8b64d3fa14261963933be34028ea0e57455baf6781fe399c2c3206c", "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d",
"sha256:ae8f34d50af2c2154035984b8b5fc5d9ed63f32fe615646ab435b05b132ca91b", "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9",
"sha256:b9aa9d8818c2e917fa2c105ad538e222a5bce59777133840b93134022a7ce650", "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0",
"sha256:bf44a9a0141a082e89c90e8d785b212a872db793a0080c20f6ae6e2a0ebf82ad", "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc",
"sha256:c0b48b98d79cf795b0916c57bebbc6d16bb43b9fc9b8c9f57f4cf05881904c75", "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15",
"sha256:da9d3c506f43e220336433dffe643fbfa40096d408cb9b7f2477892f369d5f82", "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c",
"sha256:e4082d832e36e7f9b2278bc774886ca8207346b99f278e54c9de4834f17232f7", "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a",
"sha256:e4b9b7af398c32e408c00eb4e0d33ced2f9121fd9fb978e6c1b57edd014a7d15", "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3",
"sha256:e613514a82539fc48291d01933951a13ae93b6b444a88782480be32245ed4afa", "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1",
"sha256:f5033952def24172e60493b68717792e3aebb387a8d186c43c020d9363ee7281" "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": { "click": {
"hashes": [ "hashes": [
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" "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" "version": "==7.1.2"
}, },
"flask": { "flask": {
@ -133,6 +143,7 @@
"sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19",
"sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.0" "version": "==1.1.0"
}, },
"jinja2": { "jinja2": {
@ -140,6 +151,7 @@
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" "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" "version": "==2.11.2"
}, },
"mako": { "mako": {
@ -147,6 +159,7 @@
"sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27", "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27",
"sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9" "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.3" "version": "==1.1.3"
}, },
"markupsafe": { "markupsafe": {
@ -185,6 +198,7 @@
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.1" "version": "==1.1.1"
}, },
"pycparser": { "pycparser": {
@ -192,6 +206,7 @@
"sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0",
"sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.20" "version": "==2.20"
}, },
"python-dateutil": { "python-dateutil": {
@ -199,21 +214,33 @@
"sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
"sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.8.1" "version": "==2.8.1"
}, },
"python-editor": { "python-editor": {
"hashes": [ "hashes": [
"sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d",
"sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b",
"sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8" "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8",
"sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77",
"sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522"
], ],
"version": "==1.0.4" "version": "==1.0.4"
}, },
"pytz": {
"hashes": [
"sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed",
"sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"
],
"index": "pypi",
"version": "==2020.1"
},
"six": { "six": {
"hashes": [ "hashes": [
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0" "version": "==1.15.0"
}, },
"sqlalchemy": { "sqlalchemy": {
@ -251,6 +278,7 @@
"sha256:f2e8a9c0c8813a468aa659a01af6592f71cd30237ec27c4cc0683f089f90dcfc", "sha256:f2e8a9c0c8813a468aa659a01af6592f71cd30237ec27c4cc0683f089f90dcfc",
"sha256:fe7fe11019fc3e6600819775a7d55abc5446dda07e9795f5954fdbf8a49e1c37" "sha256:fe7fe11019fc3e6600819775a7d55abc5446dda07e9795f5954fdbf8a49e1c37"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.3.19" "version": "==1.3.19"
}, },
"werkzeug": { "werkzeug": {
@ -258,6 +286,7 @@
"sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43",
"sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" "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" "version": "==1.0.1"
}, },
"wtforms": { "wtforms": {
@ -281,6 +310,7 @@
"sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
"sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.6.0" "version": "==2.6.0"
}, },
"toml": { "toml": {

View File

@ -31,4 +31,7 @@ def create_app():
from . import graph from . import graph
app.register_blueprint(graph.blueprint) app.register_blueprint(graph.blueprint)
from . import settings
app.register_blueprint(settings.blueprint)
return app return app

View File

@ -1,11 +1,13 @@
from flask import Blueprint, render_template, redirect from flask import Blueprint, render_template, redirect
from flask_wtf import FlaskForm 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 wtforms.validators import DataRequired, Email, EqualTo, Length, Optional
from flask_login import LoginManager, login_user, logout_user from flask_login import LoginManager, login_user, logout_user
import sqlalchemy.exc import sqlalchemy.exc
from .db import db, User from .db import db, User
import pytz
blueprint = Blueprint('auth', __name__) blueprint = Blueprint('auth', __name__)
login_manager = LoginManager() login_manager = LoginManager()
@ -24,6 +26,12 @@ class SignupForm(FlaskForm):
'Username', 'Username',
validators=[DataRequired(), Length(min=2), Length(max=64)] 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 = PasswordField(
'Password', 'Password',
validators=[ validators=[
@ -32,6 +40,7 @@ class SignupForm(FlaskForm):
DataRequired() DataRequired()
] ]
) )
confirm = PasswordField( confirm = PasswordField(
'Confirm password', 'Confirm password',
validators=[ validators=[
@ -39,6 +48,7 @@ class SignupForm(FlaskForm):
EqualTo('password', message='Passwords do not match') EqualTo('password', message='Passwords do not match')
] ]
) )
submit = SubmitField('Register') submit = SubmitField('Register')
@ -64,6 +74,8 @@ def register():
if form.validate_on_submit(): if form.validate_on_submit():
user = User(username=form.username.data) user = User(username=form.username.data)
user.set_password(form.password.data) user.set_password(form.password.data)
user.timezone = form.tz.data
user.start_of_day = 2
try: try:
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()

View File

@ -9,7 +9,9 @@ body {
} }
form input[type=text], form input[type=text],
form input[type=password] { form input[type=password],
form input[type=number],
form select {
@apply shadow; @apply shadow;
@apply appearance-none; @apply appearance-none;
@apply border; @apply border;
@ -21,6 +23,12 @@ form input[type=password] {
@apply leading-tight; @apply leading-tight;
} }
form input:focus,
form select:focus {
@apply outline-none;
@apply shadow-outline;
}
form div.error input { form div.error input {
@apply border-red-500; @apply border-red-500;
} }

92
app/days.py Normal file
View File

@ -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 "<Day[%s,user=%s]>" % (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

View File

@ -5,8 +5,7 @@ from flask_login import UserMixin
from flask_bcrypt import generate_password_hash, check_password_hash from flask_bcrypt import generate_password_hash, check_password_hash
from flask_migrate import Migrate from flask_migrate import Migrate
from datetime import datetime, timedelta from datetime import datetime, timedelta
from .days import Day
from . import timeutils
db = SQLAlchemy() db = SQLAlchemy()
migrate = Migrate() migrate = Migrate()
@ -21,14 +20,18 @@ class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(128), unique=True, nullable=False) username = db.Column(db.String(128), unique=True, nullable=False)
password = db.Column(db.String(128), nullable=False) password = db.Column(db.String(128), nullable=False)
created_on = db.Column(db.DateTime, index=False, created_on = db.Column(db.DateTime, index=False, unique=False,
unique=False, nullable=True) nullable=True, server_default=db.func.now())
last_login = db.Column(db.DateTime, index=False, last_login = db.Column(db.DateTime, index=False, unique=False,
unique=False, nullable=True) nullable=True) # TODO: set on login? or remove?
accomplishments = db.relationship( accomplishments = db.relationship(
'Accomplishment', backref='user', lazy=True) '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): def set_password(self, password):
self.password = generate_password_hash(password) self.password = generate_password_hash(password)
@ -42,8 +45,9 @@ class User(UserMixin, db.Model):
class Accomplishment(db.Model): class Accomplishment(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
time = db.Column(db.DateTime(), nullable=False, created_on = db.Column(db.DateTime, index=False, unique=False,
default=db.func.current_timestamp()) nullable=True, server_default=db.func.now())
time = db.Column(db.DateTime(), nullable=False)
text = db.Column(db.String(256), nullable=False) text = db.Column(db.String(256), nullable=False)
difficulty = db.Column(db.Integer) difficulty = db.Column(db.Integer)
@ -60,33 +64,35 @@ class Accomplishment(db.Model):
return "hard" return "hard"
@staticmethod @staticmethod
def get_time_range(user_id, start, end): def get_time_range(user, start: datetime, end: datetime):
return Accomplishment.query.filter( 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( 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 return result if result is not None else 0
@staticmethod @staticmethod
def get_day(user_id, day): def get_day(user, day: Day):
# TODO: allow setting custom "start of day" hour # TODO: allow setting custom "start of day" hour
start = timeutils.day(day) start = day.timestamp
end = timeutils.day_after(day) end = (day + 1).timestamp
return Accomplishment.get_time_range(user_id, start, end) return Accomplishment.get_time_range(user, 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)
@staticmethod @staticmethod
def get_today(user_id): def get_day_total(user, day: Day):
today = datetime.now() start = day.timestamp
return Accomplishment.get_day(user_id, today) end = (day + 1).timestamp
return Accomplishment.get_time_range_total(user, start, end)
@staticmethod @staticmethod
def get_today_total(user_id): def get_today(user):
today = datetime.now() today = Day.today(user)
return Accomplishment.get_day_total(user_id, today) return Accomplishment.get_day(user, today)
@staticmethod
def get_today_total(user):
today = Day.today(user)
return Accomplishment.get_day_total(user, today)

View File

@ -1,8 +1,7 @@
from flask import Blueprint, render_template from flask import Blueprint, render_template
from flask_login import login_required, current_user from flask_login import login_required, current_user
from .db import db, Accomplishment from .db import db, Accomplishment
from . import timeutils from .days import Day
blueprint = Blueprint('graph', __name__) blueprint = Blueprint('graph', __name__)
@ -13,15 +12,13 @@ def graph_svg():
count = 7 count = 7
accomplishments = [0]*count accomplishments = [0]*count
days = [""]*count days = [""]*count
day = timeutils.today() day = Day.today(current_user)
for i in range(1, count+1): 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 accomplishments[-i] = total_xp
days[-i] = day.strftime('%a')[:2] days[-i] = day.timestamp.strftime('%a')[:2]
day = timeutils.day_before(day) day -= 1
print(accomplishments)
return render_template('graph.svg', days=days, **gen_graph_data(accomplishments)), 200, {'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-cache'} return render_template('graph.svg', days=days, **gen_graph_data(accomplishments)), 200, {'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-cache'}

View File

@ -1,4 +1,3 @@
from . import timeutils
from flask import Blueprint, render_template, redirect, url_for, abort, request from flask import Blueprint, render_template, redirect, url_for, abort, request
from flask_login import current_user, login_required from flask_login import current_user, login_required
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
@ -7,6 +6,7 @@ from wtforms.validators import DataRequired, Length, NumberRange
from .db import db, Accomplishment from .db import db, Accomplishment
from datetime import datetime, timedelta from datetime import datetime, timedelta
import time import time
from .days import Day
main = Blueprint('main', __name__) main = Blueprint('main', __name__)
@ -30,46 +30,39 @@ def handle_accomplishment_submission(form):
accomplishment.difficulty = 10 accomplishment.difficulty = 10
elif form.submit_15.data: elif form.submit_15.data:
accomplishment.difficulty = 15 accomplishment.difficulty = 15
# the timestamp should be set by the database accomplishment.time = Day.today(current_user).timestamp
db.session.add(accomplishment) db.session.add(accomplishment)
db.session.commit() db.session.commit()
return redirect(url_for('main.index')) return redirect(url_for('main.index'))
def parse_day(day_string): def parse_day(day_string, user):
day_datetime = None day = None
if day_string == "today": if day_string == "today":
day_datetime = timeutils.today() day = Day.today(user)
else: else:
day_datetime = timeutils.from_str(day_string) day = Day.from_str(day_string, user)
day_string_clean = timeutils.as_str(day_datetime) return day
return {
"datetime": day_datetime,
"string": day_string_clean,
"fancy": timeutils.as_fancy_str(day_datetime),
"is_today": timeutils.is_today(day_datetime)
}
def get_day_template_data(day_string): def get_day_template_data(day_string, user):
day = parse_day(day_string) day = parse_day(day_string, user)
day_datetime = day["datetime"]
accomplishments = list(reversed( 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) total = sum(a.difficulty for a in accomplishments)
yesterday = timeutils.day_before(day_datetime) yesterday = day - 1
tomorrow = timeutils.day_after(day_datetime) tomorrow = day + 1
if timeutils.is_future(tomorrow): if tomorrow.is_future:
tomorrow = None tomorrow = None
return { return {
"day": day, "day": day,
"links": { "links": {
"yesterday": url_for('main.index', day=timeutils.as_str(yesterday)), "yesterday": url_for('main.index', day=yesterday.url),
"tomorrow": url_for('main.index', day=timeutils.as_str(tomorrow)) if tomorrow is not None else None "tomorrow": url_for('main.index', day=tomorrow.url) if tomorrow is not None else None
}, },
"accomplishments": accomplishments, "accomplishments": accomplishments,
"total_xp": sum(a.difficulty for a in accomplishments), "total_xp": sum(a.difficulty for a in accomplishments),
@ -81,6 +74,7 @@ def get_day_template_data(day_string):
@main.route('/day/<day>') @main.route('/day/<day>')
def index(day): def index(day):
if not current_user.is_authenticated: if not current_user.is_authenticated:
# TODO: handle the case when the user is on /day/<something> and is not logged in
return render_template('index.html') return render_template('index.html')
form = NewAccomplishementForm() form = NewAccomplishementForm()
@ -90,7 +84,7 @@ def index(day):
return render_template( return render_template(
'main/app.html', 'main/app.html',
form=form, 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', 'main/app.html',
form=form, form=form,
edit=True, 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: if a.user_id != current_user.id:
abort(403) abort(403)
back_url = url_for( # TODO: fix: we're using from_str when it's a datetime in the db? it works on sqlite but
'main.edit_day', day=timeutils.as_str(timeutils.day(a.time))) back_url = url_for('main.edit_day', day=Day.from_str(a.time, user).url)
form = DeleteForm() form = DeleteForm()
if form.validate_on_submit(): if form.validate_on_submit():
@ -155,8 +149,8 @@ def edit_accomplishment(accomplishment_id):
if a.user_id != current_user.id: if a.user_id != current_user.id:
abort(403) abort(403)
back_url = url_for( back_url = url_for('main.edit_day', day=Day.from_str(
'main.edit_day', day=timeutils.as_str(timeutils.day(a.time))) a.time, current_user).url)
form = EditForm(obj=a) form = EditForm(obj=a)
if form.validate_on_submit(): if form.validate_on_submit():
@ -171,28 +165,34 @@ def edit_accomplishment(accomplishment_id):
@main.route('/day/<day>/add', methods=['GET', 'POST']) @main.route('/day/<day>/add', methods=['GET', 'POST'])
@login_required @login_required
def add_day(day): def add_day(day):
day_parsed = parse_day(day) day = parse_day(day, current_user)
form = EditForm() form = EditForm()
back_url = "" back_url = ""
from_top = ("from" in request.args) and ("top" in request.args["from"]) from_top = ("from" in request.args) and ("top" in request.args["from"])
back_to_day = url_for('main.index', day=day_parsed["string"]) # to the bottom
back_to_edit = url_for('main.edit_day', day=day_parsed["string"]) # 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(): if form.validate_on_submit():
accomplishment = Accomplishment() accomplishment = Accomplishment()
accomplishment.user_id = current_user.id accomplishment.user_id = current_user.id
accomplishment.text = form.text.data accomplishment.text = form.text.data
accomplishment.difficulty = form.difficulty.data accomplishment.difficulty = form.difficulty.data
accomplishment.time = timeutils.from_str(day) accomplishment.time = day.timestamp
db.session.add(accomplishment) db.session.add(accomplishment)
db.session.commit() db.session.commit()
return redirect(back_to_day) return redirect(back_to_day)
return render_template( return render_template(
'main/edit.html', 'main/edit.html',
day=day_parsed, day=day,
form=form, form=form,
edit=True, edit=True,
cancel=back_to_day if from_top else back_to_edit cancel=back_to_day if from_top else back_to_edit

43
app/settings.py Normal file
View File

@ -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)

View File

@ -1,8 +1,19 @@
{% macro render_field(field, label=True, wrapper_class="") %} {% macro render_field(field, label=True, wrapper_class="", description="") %}
<div class="mb-4 {% if field.errors %}error{% endif %} {{ wrapper_class }}"> <div class="mb-4 {% if field.errors %}error{% endif %} {{ wrapper_class }}">
<label for="{{ field.id }}" <label for="{{ field.id }}"
class="block mb-2 text-sm font-bold text-gray-700">{% if label %}{{ field.label }}{% endif %}</label> class="block text-sm font-bold text-gray-700">{% if label %}{{ field.label }}{% endif %}</label>
<div class="mb-2 text-xs text-gray-700">{{ description }}</div>
{% if field.type == "SelectField" %}
<div class="relative">
{{ field(**kwargs)|safe }}
<div class="absolute inset-y-0 right-0 flex items-center px-2 text-gray-700 pointer-events-none">
<svg class="w-4 h-4 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" /></svg>
</div>
</div>
{% else %}
{{ field(**kwargs)|safe }} {{ field(**kwargs)|safe }}
{% endif %}
{% if field.errors %} {% if field.errors %}
<ul class="errors"> <ul class="errors">
{% for error in field.errors %} {% for error in field.errors %}

View File

@ -31,8 +31,8 @@
sizes="196x196"> sizes="196x196">
<link rel="icon" type="image/png" href="{{ static_url_for('static', filename='icons/icon-256.png') }}" <link rel="icon" type="image/png" href="{{ static_url_for('static', filename='icons/icon-256.png') }}"
sizes="256x256"> sizes="256x256">
<link rel="icon" type="image/png" href="{{ static_url_for('static', filename='icons/icon-228.png') }}" <link <link rel="icon" type="image/png" href="{{ static_url_for('static', filename='icons/icon-228.png') }}">
rel="icon" type="image/png" href="{{ static_url_for('static', filename='icons/icon-512.png') }}" <link rel="icon" type="image/png" href="{{ static_url_for('static', filename='icons/icon-512.png') }}"
sizes="512x512"> sizes="512x512">
<link rel="shortcut icon" href="{{ static_url_for('static', filename='icons/favicon.ico') }}"> <link rel="shortcut icon" href="{{ static_url_for('static', filename='icons/favicon.ico') }}">
{% endblock %} {% endblock %}
@ -47,7 +47,10 @@
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<p>Hi <span class="font-mono">{{ current_user.username }}</span>!</p> <p>Hi <span class="font-mono">{{ current_user.username }}</span>!</p>
<p> <p>
<a class="text-xs link" href="{{ url_for('auth.logout') }}">Log out.</a> <span class="pr-1"><a class="text-xs link" href="{{ url_for('main.index') }}">Home</a></span>
<span class="px-1"><a class="text-xs link" href="{{ url_for('settings.settings') }}">Settings</a></span>
<span class="pl-1"><a class="text-xs link" href="{{ url_for('auth.logout') }}">Log
out</a></span>
</p> </p>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -1,10 +1,9 @@
{% extends "_skel.html" %} {% extends "_skel.html" %}
{% block title %}Log in{% endblock %} {% block title %}Log in{% endblock %}
{% block header_width %}max-w-xs{% endblock %}
{% from "_formhelpers.html" import render_field %} {% from "_formhelpers.html" import render_field %}
{% block content %} {% block content %}
<div class="w-full max-w-xs mx-auto"> <div class="w-full max-w-lg mx-auto card">
<form method="POST" class="card auth-form"> <form method="POST" class="w-full max-w-xs mx-auto auth-form">
{{ form.csrf_token }} {{ form.csrf_token }}
{{ render_field(form.username) }} {{ render_field(form.username) }}
{{ render_field(form.password) }} {{ render_field(form.password) }}

View File

@ -1,12 +1,10 @@
{% extends "_skel.html" %} {% extends "_skel.html" %}
{% block title %}Log out{% endblock %} {% block title %}Log out{% endblock %}
{% block header_width %}max-w-xs{% endblock %}
{% block header_user %}{% endblock %}
{% from "_formhelpers.html" import render_field %} {% from "_formhelpers.html" import render_field %}
{% block content %} {% block content %}
<div class="w-full max-w-xs mx-auto"> <div class="w-full max-w-lg mx-auto card">
<form method="POST" class="card auth-form"> <form method="POST" class="w-full max-w-xs mx-auto auth-form">
<h3>Are you sure you want to log out?</h3> <h3 class="text-center">Are you sure you want to log out?</h3>
{{ form.csrf_token }} {{ form.csrf_token }}
{{ render_field(form.submit, False) }} {{ render_field(form.submit, False) }}
<p class="text-xs text-center"> <p class="text-xs text-center">

View File

@ -1,15 +1,27 @@
{% extends "_skel.html" %} {% extends "_skel.html" %}
{% block title %}Register{% endblock %} {% block title %}Register{% endblock %}
{% block header_width %}max-w-xs{% endblock %}
{% from "_formhelpers.html" import render_field %} {% from "_formhelpers.html" import render_field %}
{% block content %} {% block content %}
<div class="w-full max-w-xs mx-auto"> <div class="w-full max-w-lg mx-auto card">
<form method="POST" class="card auth-form"> <form method="POST" class="w-full max-w-xs mx-auto auth-form">
{{ form.csrf_token }} {{ form.csrf_token }}
{{ render_field(form.username) }} {{ render_field(form.username) }}
{{ render_field(form.tz) }}
{{ render_field(form.password) }} {{ render_field(form.password) }}
{{ render_field(form.confirm) }} {{ render_field(form.confirm) }}
{{ render_field(form.submit, False) }} {{ render_field(form.submit, False) }}
</form> </form>
</div> </div>
<script>
var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
var matched = Array.prototype.filter.call(
document.getElementById("tz").options,
(a) => a.value == tz
)
if (matched.length > 0) {
document.getElementById("tz").value = matched[0].value
}
</script>
{% endblock %} {% endblock %}

View File

@ -1,5 +1,5 @@
{% extends "_skel.html" %} {% extends "_skel.html" %}
{% block title %}{{ day.fancy }}{% endblock %} {% block title %}{{ day.pretty }}{% endblock %}
{% block content %} {% block content %}
<div class="max-w-xl mx-auto card"> <div class="max-w-xl mx-auto card">
<form method="POST" action="{{ url_for('main.index') }}"> <form method="POST" action="{{ url_for('main.index') }}">
@ -18,23 +18,23 @@
<div class="max-w-lg mx-auto card"> <div class="max-w-lg mx-auto card">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div> <div>
<h3 class="text-2xl">{{ day.fancy }}</h3> <h3 class="text-2xl">{{ day.pretty }}</h3>
</div> </div>
{% if edit %} {% if edit %}
<div><a href="{{ url_for('main.index', day=day.string) }}" class="link">done</a></div> <div><a href="{{ url_for('main.index', day=day.url) }}" class="link">done</a></div>
{% else %} {% else %}
{% if accomplishments %} {% if accomplishments %}
<div><a href="{{ url_for('main.edit_day', day=day.string) }}" class="link">edit</a></div> <div><a href="{{ url_for('main.edit_day', day=day.url) }}" class="link">edit</a></div>
{% else %} {% else %}
{% if not day.is_today %} {% if not day.is_today %}
<div><a href="{{ url_for('main.add_day', day=day.string, from="top") }}" class="link">add</a></div> <div><a href="{{ url_for('main.add_day', day=day.url, from="top") }}" class="link">add</a></div>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
{% if edit %} {% if edit %}
<div class="my-1 ml-2 text-sm accomplishment"> <div class="my-1 ml-2 text-sm accomplishment">
<div><a href="{{ url_for('main.add_day', day=day.string) }}" class="link">Add accomplishment</a></div> <div><a href="{{ url_for('main.add_day', day=day.url) }}" class="link">Add accomplishment</a></div>
</div> </div>
<hr> <hr>
{% endif %} {% endif %}

View File

@ -5,12 +5,12 @@
{% if day %} {% if day %}
<div class="w-full max-w-lg mx-auto card"> <div class="w-full max-w-lg mx-auto card">
<div class="text-xl text-center"> <div class="text-xl text-center">
You're adding an accomplishment made on {{ day.fancy }}. You're adding an accomplishment made on {{ day.pretty }}.
</div> </div>
</div> </div>
{% endif %} {% endif %}
<form method="POST" class="w-full max-w-lg mx-auto card" <form method="POST" class="w-full max-w-lg mx-auto card" {% if day %}action="{{ url_for('main.add_day', day=day.url) }}"
{% if day %}action="{{ url_for('main.add_day', day=day.string) }}" {% endif %}> {% endif %}>
{{ form.csrf_token }} {{ form.csrf_token }}
{{ render_field(form.text) }} {{ render_field(form.text) }}
{{ render_field(form.difficulty) }} {{ render_field(form.difficulty) }}

View File

@ -0,0 +1,16 @@
{% extends "_skel.html" %}
{% block title %}Settings{% endblock %}
{% from "_formhelpers.html" import render_field %}
{% block content %}
<div class="w-full max-w-lg mx-auto card">
<!-- TODO: this shouldn't use the auth-form class, that class should be generalized -->
<form method="POST" class="max-w-xs mx-auto auth-form">
{{ 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 %}<p class="text-center text-green-800">Saved successfuly.</p>{% endif %}
</form>
</div>
{% endblock %}
<!-- TODO: make form styling consistent -->

View File

@ -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()

View File

@ -1,4 +1,5 @@
from __future__ import with_statement from __future__ import with_statement
from flask import current_app
import logging import logging
from logging.config import fileConfig from logging.config import fileConfig
@ -21,7 +22,6 @@ logger = logging.getLogger('alembic.env')
# for 'autogenerate' support # for 'autogenerate' support
# from myapp import mymodel # from myapp import mymodel
# target_metadata = mymodel.Base.metadata # target_metadata = mymodel.Base.metadata
from flask import current_app
config.set_main_option( config.set_main_option(
'sqlalchemy.url', 'sqlalchemy.url',
str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%')) str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%'))
@ -83,6 +83,7 @@ def run_migrations_online():
connection=connection, connection=connection,
target_metadata=target_metadata, target_metadata=target_metadata,
process_revision_directives=process_revision_directives, process_revision_directives=process_revision_directives,
render_as_batch=True,
**current_app.extensions['migrate'].configure_args **current_app.extensions['migrate'].configure_args
) )

View File

@ -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 ###

View File

@ -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 ###