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:
parent
a899d1c1be
commit
cc6e0efab8
4
Pipfile
4
Pipfile
|
|
@ -14,6 +14,4 @@ flask-bcrypt = "*"
|
|||
flask-migrate = "*"
|
||||
flask-wtf = "*"
|
||||
flask-static-digest = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.7"
|
||||
pytz = "*"
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
app/auth.py
14
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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
60
app/db.py
60
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)
|
||||
|
|
|
|||
13
app/graph.py
13
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'}
|
||||
|
||||
|
|
|
|||
66
app/main.py
66
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/<day>')
|
||||
def index(day):
|
||||
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')
|
||||
|
||||
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/<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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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 }}">
|
||||
<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 }}
|
||||
{% endif %}
|
||||
{% if field.errors %}
|
||||
<ul class="errors">
|
||||
{% for error in field.errors %}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@
|
|||
sizes="196x196">
|
||||
<link rel="icon" type="image/png" href="{{ static_url_for('static', filename='icons/icon-256.png') }}"
|
||||
sizes="256x256">
|
||||
<link rel="icon" type="image/png" href="{{ static_url_for('static', filename='icons/icon-228.png') }}" <link
|
||||
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-228.png') }}">
|
||||
<link rel="icon" type="image/png" href="{{ static_url_for('static', filename='icons/icon-512.png') }}"
|
||||
sizes="512x512">
|
||||
<link rel="shortcut icon" href="{{ static_url_for('static', filename='icons/favicon.ico') }}">
|
||||
{% endblock %}
|
||||
|
|
@ -47,7 +47,10 @@
|
|||
{% if current_user.is_authenticated %}
|
||||
<p>Hi <span class="font-mono">{{ current_user.username }}</span>!</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>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
<div class="w-full max-w-xs mx-auto">
|
||||
<form method="POST" class="card auth-form">
|
||||
<div class="w-full max-w-lg mx-auto card">
|
||||
<form method="POST" class="w-full max-w-xs mx-auto auth-form">
|
||||
{{ form.csrf_token }}
|
||||
{{ render_field(form.username) }}
|
||||
{{ render_field(form.password) }}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
<div class="w-full max-w-xs mx-auto">
|
||||
<form method="POST" class="card auth-form">
|
||||
<h3>Are you sure you want to log out?</h3>
|
||||
<div class="w-full max-w-lg mx-auto card">
|
||||
<form method="POST" class="w-full max-w-xs mx-auto auth-form">
|
||||
<h3 class="text-center">Are you sure you want to log out?</h3>
|
||||
{{ form.csrf_token }}
|
||||
{{ render_field(form.submit, False) }}
|
||||
<p class="text-xs text-center">
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
<div class="w-full max-w-xs mx-auto">
|
||||
<form method="POST" class="card auth-form">
|
||||
<div class="w-full max-w-lg mx-auto card">
|
||||
<form method="POST" class="w-full max-w-xs mx-auto auth-form">
|
||||
{{ form.csrf_token }}
|
||||
{{ render_field(form.username) }}
|
||||
{{ render_field(form.tz) }}
|
||||
{{ render_field(form.password) }}
|
||||
{{ render_field(form.confirm) }}
|
||||
{{ render_field(form.submit, False) }}
|
||||
</form>
|
||||
</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 %}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "_skel.html" %}
|
||||
{% block title %}{{ day.fancy }}{% endblock %}
|
||||
{% block title %}{{ day.pretty }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-xl mx-auto card">
|
||||
<form method="POST" action="{{ url_for('main.index') }}">
|
||||
|
|
@ -18,23 +18,23 @@
|
|||
<div class="max-w-lg mx-auto card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-2xl">{{ day.fancy }}</h3>
|
||||
<h3 class="text-2xl">{{ day.pretty }}</h3>
|
||||
</div>
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% if edit %}
|
||||
<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>
|
||||
<hr>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@
|
|||
{% if day %}
|
||||
<div class="w-full max-w-lg mx-auto card">
|
||||
<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>
|
||||
{% endif %}
|
||||
<form method="POST" class="w-full max-w-lg mx-auto card"
|
||||
{% if day %}action="{{ url_for('main.add_day', day=day.string) }}" {% endif %}>
|
||||
<form method="POST" class="w-full max-w-lg mx-auto card" {% if day %}action="{{ url_for('main.add_day', day=day.url) }}"
|
||||
{% endif %}>
|
||||
{{ form.csrf_token }}
|
||||
{{ render_field(form.text) }}
|
||||
{{ render_field(form.difficulty) }}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
|
|
@ -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 ###
|
||||
Loading…
Reference in New Issue