Browse Source
Implement timezones and start of day. (#11)
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/11pull/13/head
22 changed files with 455 additions and 188 deletions
Split View
Diff Options
-
4Pipfile
-
102Pipfile.lock
-
3app/__init__.py
-
14app/auth.py
-
10app/css/main.css
-
92app/days.py
-
58app/db.py
-
13app/graph.py
-
66app/main.py
-
43app/settings.py
-
15app/templates/_formhelpers.html
-
9app/templates/_skel.html
-
5app/templates/auth/login.html
-
8app/templates/auth/logout.html
-
18app/templates/auth/register.html
-
12app/templates/main/app.html
-
6app/templates/main/edit.html
-
16app/templates/settings.html
-
54app/timeutils.py
-
3migrations/env.py
-
39migrations/versions/517c61e085a2_add_timezone_and_start_of_day_to_user.py
-
53migrations/versions/687170684a50_add_created_on_to_accomplishment.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 |
@ -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,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 %} |
@ -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() |
@ -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 ### |
Write
Preview
Loading…
Cancel
Save
Reference in new issue