Browse Source

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
pull/13/head
Wojciech Kwolek 5 months ago
parent
commit
cc6e0efab8
22 changed files with 455 additions and 188 deletions
  1. 4
      Pipfile
  2. 102
      Pipfile.lock
  3. 3
      app/__init__.py
  4. 14
      app/auth.py
  5. 10
      app/css/main.css
  6. 92
      app/days.py
  7. 58
      app/db.py
  8. 13
      app/graph.py
  9. 66
      app/main.py
  10. 43
      app/settings.py
  11. 15
      app/templates/_formhelpers.html
  12. 9
      app/templates/_skel.html
  13. 5
      app/templates/auth/login.html
  14. 8
      app/templates/auth/logout.html
  15. 18
      app/templates/auth/register.html
  16. 12
      app/templates/main/app.html
  17. 6
      app/templates/main/edit.html
  18. 16
      app/templates/settings.html
  19. 54
      app/timeutils.py
  20. 3
      migrations/env.py
  21. 39
      migrations/versions/517c61e085a2_add_timezone_and_start_of_day_to_user.py
  22. 53
      migrations/versions/687170684a50_add_created_on_to_accomplishment.py

4
Pipfile

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

102
Pipfile.lock

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

3
app/__init__.py

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

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

10
app/css/main.css

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

92
app/days.py

@ -0,0 +1,92 @@
from datetime import datetime, timedelta, timezone
import pytz
def _suffix(d):
return 'th' if 11 <= d <= 13 else {1: 'st', 2: 'nd', 3: 'rd'}.get(d % 10, 'th')
class Day:
def __init__(self, year, month, day, user):
self.user = user
self._timestamp = datetime(year, month, day, tzinfo=timezone.utc)
@staticmethod
def from_str(string, user):
return Day._from_timestamp(datetime.strptime(string, "%Y-%m-%d"), user)
@staticmethod
def _from_timestamp(timestamp, user):
# not exposed because it is only an utilty function, not aware of the
# user's time zone and start-of-day hour
return Day(timestamp.year, timestamp.month, timestamp.day, user)
def __eq__(self, other):
if not isinstance(other, self.__class__):
return False
return self.year == other.year \
and self.month == other.month \
and self.day == other.day
def __add__(self, other, oper="+"):
if not isinstance(other, int):
raise TypeError(
'Unsupported operands for "+". The right hand side needs to be a number.')
else:
return Day._from_timestamp(self.timestamp + timedelta(days=other), self.user)
def __sub__(self, other):
if not isinstance(other, int):
raise TypeError(
'Unsupported operands for "-". The right hand side needs to be a number.')
else:
return Day._from_timestamp(self.timestamp + timedelta(days=-other), self.user)
def __lt__(self, other): return self.timestamp < other.timestamp
def __gt__(self, other): return self.timestamp > other.timestamp
def __le__(self, other): return self < other or self == other
def __ge__(self, other): return self > other or self == other
def __repr__(self): return "<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

58
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)
start = day.timestamp
end = (day + 1).timestamp
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
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(user_id):
today = datetime.now()
return Accomplishment.get_day(user_id, today)
def get_today(user):
today = Day.today(user)
return Accomplishment.get_day(user, today)
@staticmethod
def get_today_total(user_id):
today = datetime.now()
return Accomplishment.get_day_total(user_id, today)
def get_today_total(user):
today = Day.today(user)
return Accomplishment.get_day_total(user, today)

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

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

43
app/settings.py

@ -0,0 +1,43 @@
from flask import Blueprint, render_template
from flask_login import current_user, login_required
from flask_wtf import FlaskForm
from .db import db
from wtforms import SelectField, SubmitField
from wtforms.fields.html5 import IntegerField
from wtforms.widgets.html5 import NumberInput
from wtforms.validators import DataRequired, NumberRange
import pytz
blueprint = Blueprint('settings', __name__)
class SettingsForm(FlaskForm):
timezone = SelectField(
'Timezone', choices=list(map(lambda x: (x, x.replace("_", " ")), pytz.all_timezones)),
validators=[
DataRequired()
])
start_of_day = IntegerField(
'Start of day hour',
widget=NumberInput(min=0, max=23),
validators=[
DataRequired(),
NumberRange(min=0, max=23)
]
)
submit = SubmitField('Save')
@blueprint.route('/settings', methods=['GET', 'POST'])
@login_required
def settings():
form = SettingsForm(obj=current_user)
if form.validate_on_submit():
current_user.timezone = form.timezone.data
current_user.start_of_day = form.start_of_day.data
db.session.commit()
return render_template('settings.html', form=form, success=True)
return render_template('settings.html', form=form)

15
app/templates/_formhelpers.html

@ -1,8 +1,19 @@
{% macro render_field(field, label=True, wrapper_class="") %}
{% macro render_field(field, label=True, wrapper_class="", description="") %}
<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 %}

9
app/templates/_skel.html

@ -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 %}

5
app/templates/auth/login.html

@ -1,10 +1,9 @@
{% extends "_skel.html" %}
{% block title %}Log in{% endblock %}
{% block header_width %}max-w-xs{% endblock %}
{% from "_formhelpers.html" import render_field %}
{% block content %}
<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) }}

8
app/templates/auth/logout.html

@ -1,12 +1,10 @@
{% extends "_skel.html" %}
{% block title %}Log out{% endblock %}
{% block header_width %}max-w-xs{% endblock %}
{% block header_user %}{% endblock %}
{% from "_formhelpers.html" import render_field %}
{% block content %}
<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">

18
app/templates/auth/register.html

@ -1,15 +1,27 @@
{% extends "_skel.html" %}
{% block title %}Register{% endblock %}
{% block header_width %}max-w-xs{% endblock %}
{% from "_formhelpers.html" import render_field %}
{% block content %}
<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 %}

12
app/templates/main/app.html

@ -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 %}

6
app/templates/main/edit.html

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

16
app/templates/settings.html

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

54
app/timeutils.py

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

3
migrations/env.py

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

39
migrations/versions/517c61e085a2_add_timezone_and_start_of_day_to_user.py

@ -0,0 +1,39 @@
"""add timezone and start-of-day to user
Revision ID: 517c61e085a2
Revises: cd3b1ad8c50b
Create Date: 2020-09-25 00:16:53.532597
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '517c61e085a2'
down_revision = 'cd3b1ad8c50b'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('user', sa.Column(
'start_of_day', sa.Integer(), nullable=True))
op.add_column('user', sa.Column(
'timezone', sa.String(length=64), nullable=True))
sa.orm.Session(bind=op.get_bind()).commit()
# batch mode is needed to support sqlite, which doesn't support ALTER COLUMN
with op.batch_alter_table('user') as batch_op:
batch_op.execute("UPDATE user SET start_of_day = 2")
batch_op.execute("UPDATE user SET timezone = 'Europe/Warsaw'")
batch_op.alter_column('start_of_day', nullable=False)
batch_op.alter_column('timezone', nullable=False)
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'timezone')
op.drop_column('user', 'start_of_day')
# ### end Alembic commands ###

53
migrations/versions/687170684a50_add_created_on_to_accomplishment.py

@ -0,0 +1,53 @@
"""Add created_on to Accomplishment
Revision ID: 687170684a50
Revises: 517c61e085a2
Create Date: 2020-09-26 17:43:11.471553
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime, timezone
# revision identifiers, used by Alembic.
revision = '687170684a50'
down_revision = '517c61e085a2'
branch_labels = None
depends_on = None
accomphelper = sa.Table(
'accomplishment',
sa.MetaData(),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('time', sa.DateTime(), nullable=False),
sa.Column('created_on', sa.DateTime(), nullable=True),
)
def upgrade():
with op.batch_alter_table('accomplishment', recreate='always') as batch_op:
batch_op.add_column(
sa.Column(
'created_on', sa.DateTime(),
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True
))
connection = op.get_bind()
for accomplishment in connection.execute(accomphelper.select()):
t = accomplishment.time
connection.execute(
accomphelper.update().where(
accomphelper.c.id == accomplishment.id
).values(
created_on=accomplishment.time,
time=datetime(t.year, t.month, t.day, tzinfo=timezone.utc)
)
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('accomplishment', 'created_on')
# ### end Alembic commands ###
Loading…
Cancel
Save