Fix timezone issues; add user-friendly due date entry; add client-side auto-update to the table fields
Closes #15 and makes the UI for making and viewing task info quite a bit nicer.
This commit is contained in:
parent
9136628cd3
commit
991717687b
22 changed files with 1006 additions and 79 deletions
|
|
@ -9,6 +9,8 @@
|
||||||
- humanize (for generating human-readable timedeltas)
|
- humanize (for generating human-readable timedeltas)
|
||||||
- nh3 (for markdown and general HTML sanitization)
|
- nh3 (for markdown and general HTML sanitization)
|
||||||
- scour (for SVG icon processing)
|
- scour (for SVG icon processing)
|
||||||
|
- requests
|
||||||
|
- markdown
|
||||||
|
|
||||||
## Already Included
|
## Already Included
|
||||||
- Certain tag definitions from bleach-allowlist, used in markdown sanitization.
|
- Certain tag definitions from bleach-allowlist, used in markdown sanitization.
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
from datetime import datetime
|
|
||||||
import logging
|
import logging
|
||||||
import humanize
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from flask import Flask, render_template, url_for
|
from flask import Flask, render_template, url_for
|
||||||
|
|
||||||
|
|
@ -11,6 +9,7 @@ from taskflower.api import APIBase
|
||||||
from taskflower.db.model.user import User
|
from taskflower.db.model.user import User
|
||||||
from taskflower.sanitize.markdown import SafeHTML, render_and_sanitize
|
from taskflower.sanitize.markdown import SafeHTML, render_and_sanitize
|
||||||
from taskflower.tools.icons import get_icon, svg_bp
|
from taskflower.tools.icons import get_icon, svg_bp
|
||||||
|
from taskflower.util.time import render_abstime, render_reltime
|
||||||
from taskflower.web import web_base
|
from taskflower.web import web_base
|
||||||
|
|
||||||
from taskflower.tools.hibp import hibp_bp
|
from taskflower.tools.hibp import hibp_bp
|
||||||
|
|
@ -59,21 +58,6 @@ def template_utility_fns():
|
||||||
+ ')'
|
+ ')'
|
||||||
)
|
)
|
||||||
|
|
||||||
def reltime(until: datetime) -> str:
|
|
||||||
''' Turn a timestamp into a human-readable relative value, relative
|
|
||||||
to the moment this function is called.
|
|
||||||
'''
|
|
||||||
now = datetime.now()
|
|
||||||
delta = now - until
|
|
||||||
if now > until:
|
|
||||||
return humanize.naturaldelta(
|
|
||||||
delta
|
|
||||||
) + ' ago'
|
|
||||||
else:
|
|
||||||
return 'in ' + humanize.naturaldelta(
|
|
||||||
delta
|
|
||||||
)
|
|
||||||
|
|
||||||
def can_generate_sign_up_codes(usr: User) -> bool:
|
def can_generate_sign_up_codes(usr: User) -> bool:
|
||||||
match config.sign_up_mode:
|
match config.sign_up_mode:
|
||||||
case SignUpMode.OPEN:
|
case SignUpMode.OPEN:
|
||||||
|
|
@ -98,7 +82,8 @@ def template_utility_fns():
|
||||||
|
|
||||||
return dict(
|
return dict(
|
||||||
literal_call=literal_call,
|
literal_call=literal_call,
|
||||||
reltime=reltime,
|
reltime=render_reltime,
|
||||||
|
abstime=render_abstime,
|
||||||
can_generate_sign_up_codes=can_generate_sign_up_codes,
|
can_generate_sign_up_codes=can_generate_sign_up_codes,
|
||||||
render_as_markdown=render_as_markdown,
|
render_as_markdown=render_as_markdown,
|
||||||
icon=icon
|
icon=icon
|
||||||
|
|
|
||||||
|
|
@ -134,8 +134,8 @@ def _create_user_role(user: User) -> Either[Exception, UserRole]:
|
||||||
def _gen_user_namespace(user: User) -> Either[Exception, Namespace]:
|
def _gen_user_namespace(user: User) -> Either[Exception, Namespace]:
|
||||||
try:
|
try:
|
||||||
new_ns = Namespace(
|
new_ns = Namespace(
|
||||||
name=f'{user.display_name}\'s Namespace'[:64], # pyright:ignore[reportCallIssue]
|
name=f'{user.display_name}\'s Zone'[:64], # pyright:ignore[reportCallIssue]
|
||||||
description='Your default namespace!' # pyright:ignore[reportCallIssue]
|
description='Your default zone!' # pyright:ignore[reportCallIssue]
|
||||||
)
|
)
|
||||||
|
|
||||||
db.session.add(new_ns)
|
db.session.add(new_ns)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
from secrets import token_hex
|
from secrets import token_hex
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
@ -8,6 +8,7 @@ from taskflower.config import SignUpMode, config
|
||||||
from taskflower.db.model.codes import SignUpCode
|
from taskflower.db.model.codes import SignUpCode
|
||||||
from taskflower.db.model.user import User
|
from taskflower.db.model.user import User
|
||||||
from taskflower.types.either import Either, Left, Right
|
from taskflower.types.either import Either, Left, Right
|
||||||
|
from taskflower.util.time import now
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -17,7 +18,7 @@ def _ensure_at_least_one_admin(db: SQLAlchemy) -> Either[Exception, None]:
|
||||||
return db.session.query(
|
return db.session.query(
|
||||||
SignUpCode
|
SignUpCode
|
||||||
).filter(
|
).filter(
|
||||||
SignUpCode.expires > datetime.now()
|
SignUpCode.expires > now()
|
||||||
).filter(
|
).filter(
|
||||||
SignUpCode.grants_admin
|
SignUpCode.grants_admin
|
||||||
).filter(
|
).filter(
|
||||||
|
|
@ -30,7 +31,7 @@ def _ensure_at_least_one_admin(db: SQLAlchemy) -> Either[Exception, None]:
|
||||||
delete_count = db.session.query(
|
delete_count = db.session.query(
|
||||||
SignUpCode
|
SignUpCode
|
||||||
).filter(
|
).filter(
|
||||||
SignUpCode.expires > datetime.now()
|
SignUpCode.expires > now()
|
||||||
).filter(
|
).filter(
|
||||||
SignUpCode.grants_admin
|
SignUpCode.grants_admin
|
||||||
).filter(
|
).filter(
|
||||||
|
|
@ -63,8 +64,8 @@ def _ensure_at_least_one_admin(db: SQLAlchemy) -> Either[Exception, None]:
|
||||||
else:
|
else:
|
||||||
code = SignUpCode(
|
code = SignUpCode(
|
||||||
code=token_hex(16), # pyright:ignore[reportCallIssue]
|
code=token_hex(16), # pyright:ignore[reportCallIssue]
|
||||||
created=datetime.now(), # pyright:ignore[reportCallIssue]
|
created=now(), # pyright:ignore[reportCallIssue]
|
||||||
expires=datetime.now() + timedelta(hours=1), # pyright:ignore[reportCallIssue]
|
expires=now() + timedelta(hours=1), # pyright:ignore[reportCallIssue]
|
||||||
created_by=-1, # pyright:ignore[reportCallIssue]
|
created_by=-1, # pyright:ignore[reportCallIssue]
|
||||||
grants_admin=True # pyright:ignore[reportCallIssue]
|
grants_admin=True # pyright:ignore[reportCallIssue]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ class HIBPLocalCacheMode(EnumFromEnv):
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ConfigType:
|
class ConfigType:
|
||||||
# Application secrets
|
# Application secrets
|
||||||
db_secret : str # Secret value used to 'pepper' password hashes. This MUST
|
db_secret : str = 'potato' # Secret value used to 'pepper' password hashes. This MUST
|
||||||
# be generated randomly and cryptographically securely.
|
# be generated randomly and cryptographically securely.
|
||||||
# For an example of how to do this:
|
# For an example of how to do this:
|
||||||
# https://flask.palletsprojects.com/en/stable/config/#SECRET_KEY
|
# https://flask.palletsprojects.com/en/stable/config/#SECRET_KEY
|
||||||
|
|
@ -116,7 +116,7 @@ class ConfigType:
|
||||||
# In a multi-node environment, this key must be the same
|
# In a multi-node environment, this key must be the same
|
||||||
# across all nodes.
|
# across all nodes.
|
||||||
|
|
||||||
app_secret : str # Secret value used to generate session tokens. This should
|
app_secret : str = 'potato' # Secret value used to generate session tokens. This should
|
||||||
# ALSO be generated securely, and it should be different from
|
# ALSO be generated securely, and it should be different from
|
||||||
# ``db_secret``
|
# ``db_secret``
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,23 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
|
||||||
from sqlalchemy.sql import func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from taskflower.db import db
|
from taskflower.db import db
|
||||||
|
from taskflower.util.time import now
|
||||||
|
|
||||||
class SignUpCode(db.Model):
|
class SignUpCode(db.Model):
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
code: Mapped[str] = mapped_column(String(32), unique=True)
|
code: Mapped[str] = mapped_column(String(32), unique=True)
|
||||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
|
||||||
expires: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
expires: Mapped[datetime] = mapped_column(DateTime(timezone=False))
|
||||||
grants_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
grants_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
|
created_by: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
|
||||||
|
|
||||||
class NamespaceInviteCode(db.Model):
|
class NamespaceInviteCode(db.Model):
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
code: Mapped[str] = mapped_column(String(32), unique=True)
|
code: Mapped[str] = mapped_column(String(32), unique=True)
|
||||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
|
||||||
expires: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
expires: Mapped[datetime] = mapped_column(DateTime(timezone=False))
|
||||||
|
|
||||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
|
created_by: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
|
||||||
for_role: Mapped[int] = mapped_column(Integer, ForeignKey('namespace_role.id'))
|
for_role: Mapped[int] = mapped_column(Integer, ForeignKey('namespace_role.id', ondelete='CASCADE'))
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from taskflower.db import db
|
from taskflower.db import db
|
||||||
|
from taskflower.util.time import now
|
||||||
|
|
||||||
|
|
||||||
class Namespace(db.Model):
|
class Namespace(db.Model):
|
||||||
|
|
@ -11,12 +12,12 @@ class Namespace(db.Model):
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(64))
|
name: Mapped[str] = mapped_column(String(64))
|
||||||
description: Mapped[str] = mapped_column(String)
|
description: Mapped[str] = mapped_column(String)
|
||||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
|
||||||
|
|
||||||
|
|
||||||
class TaskToNamespace(db.Model):
|
class TaskToNamespace(db.Model):
|
||||||
__tablename__: str = 'task_to_namespace'
|
__tablename__: str = 'task_to_namespace'
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id'))
|
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE'))
|
||||||
task: Mapped[int] = mapped_column(Integer, ForeignKey('task.id'))
|
task: Mapped[int] = mapped_column(Integer, ForeignKey('task.id', ondelete='CASCADE'))
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from taskflower.db import db
|
from taskflower.db import db
|
||||||
|
from taskflower.util.time import now
|
||||||
|
|
||||||
|
|
||||||
class NamespaceRole(db.Model):
|
class NamespaceRole(db.Model):
|
||||||
|
|
@ -12,15 +13,15 @@ class NamespaceRole(db.Model):
|
||||||
permissions: Mapped[int] = mapped_column(Integer)
|
permissions: Mapped[int] = mapped_column(Integer)
|
||||||
perms_deny: Mapped[int] = mapped_column(Integer)
|
perms_deny: Mapped[int] = mapped_column(Integer)
|
||||||
priority: Mapped[int] = mapped_column(Integer)
|
priority: Mapped[int] = mapped_column(Integer)
|
||||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
|
||||||
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id'))
|
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id'))
|
||||||
|
|
||||||
class UserToNamespaceRole(db.Model):
|
class UserToNamespaceRole(db.Model):
|
||||||
__tablename__: str = 'user_to_namespace_role'
|
__tablename__: str = 'user_to_namespace_role'
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
|
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
|
||||||
role: Mapped[int] = mapped_column(Integer, ForeignKey('namespace_role.id'))
|
role: Mapped[int] = mapped_column(Integer, ForeignKey('namespace_role.id', ondelete='CASCADE'))
|
||||||
|
|
||||||
class UserRole(db.Model):
|
class UserRole(db.Model):
|
||||||
__tablename__: str = 'user_role'
|
__tablename__: str = 'user_role'
|
||||||
|
|
@ -30,12 +31,12 @@ class UserRole(db.Model):
|
||||||
permissions: Mapped[int] = mapped_column(Integer)
|
permissions: Mapped[int] = mapped_column(Integer)
|
||||||
perms_deny: Mapped[int] = mapped_column(Integer)
|
perms_deny: Mapped[int] = mapped_column(Integer)
|
||||||
priority: Mapped[int] = mapped_column(Integer)
|
priority: Mapped[int] = mapped_column(Integer)
|
||||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
|
||||||
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
|
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
|
||||||
|
|
||||||
class UserToUserRole(db.Model):
|
class UserToUserRole(db.Model):
|
||||||
__tablename__: str = 'user_to_user_role'
|
__tablename__: str = 'user_to_user_role'
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
|
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
|
||||||
role: Mapped[int] = mapped_column(Integer, ForeignKey('user_role.id'))
|
role: Mapped[int] = mapped_column(Integer, ForeignKey('user_role.id', ondelete='CASCADE'))
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy.sql import func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, DateTime
|
from sqlalchemy import Boolean, ForeignKey, Integer, String, DateTime
|
||||||
|
|
||||||
from taskflower.db import db
|
from taskflower.db import db
|
||||||
|
from taskflower.util.time import now
|
||||||
|
|
||||||
class Task(db.Model):
|
class Task(db.Model):
|
||||||
__tablename__: str = 'task'
|
__tablename__: str = 'task'
|
||||||
|
|
@ -11,7 +11,8 @@ class Task(db.Model):
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(64))
|
name: Mapped[str] = mapped_column(String(64))
|
||||||
description: Mapped[str] = mapped_column(String)
|
description: Mapped[str] = mapped_column(String)
|
||||||
due: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
due: Mapped[datetime] = mapped_column(DateTime(timezone=False))
|
||||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
|
||||||
complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id'))
|
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE'))
|
||||||
|
owner: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
|
||||||
|
|
@ -2,11 +2,12 @@ from datetime import datetime
|
||||||
from typing import override
|
from typing import override
|
||||||
from sqlalchemy import Boolean, DateTime, Integer, String
|
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.sql import func
|
|
||||||
from taskflower.db import db
|
from taskflower.db import db
|
||||||
|
|
||||||
from flask_login import UserMixin # pyright:ignore[reportMissingTypeStubs]
|
from flask_login import UserMixin # pyright:ignore[reportMissingTypeStubs]
|
||||||
|
|
||||||
|
from taskflower.util.time import now
|
||||||
|
|
||||||
|
|
||||||
class User(db.Model, UserMixin):
|
class User(db.Model, UserMixin):
|
||||||
__tablename__: str = 'user'
|
__tablename__: str = 'user'
|
||||||
|
|
@ -14,7 +15,7 @@ class User(db.Model, UserMixin):
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
username: Mapped[str] = mapped_column(String(32), unique=True)
|
username: Mapped[str] = mapped_column(String(32), unique=True)
|
||||||
display_name: Mapped[str] = mapped_column(String(256))
|
display_name: Mapped[str] = mapped_column(String(256))
|
||||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean)
|
enabled: Mapped[bool] = mapped_column(Boolean)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
from datetime import timezone
|
||||||
from typing import override
|
from typing import override
|
||||||
from wtforms import DateTimeLocalField, SelectField, StringField, TextAreaField, ValidationError, validators
|
from zoneinfo import ZoneInfo
|
||||||
|
from wtforms import Field, Form, SelectField, StringField, TextAreaField, ValidationError, validators
|
||||||
|
|
||||||
from taskflower.auth.permission import NamespacePermissionType
|
from taskflower.auth.permission import NamespacePermissionType
|
||||||
from taskflower.auth.permission.checks import assert_user_perms_on_namespace, assert_user_perms_on_task
|
from taskflower.auth.permission.checks import assert_user_perms_on_namespace, assert_user_perms_on_task
|
||||||
|
|
@ -12,6 +14,35 @@ from taskflower.form import FormCreatesObjectWithUser, FormEditsObjectWithUser
|
||||||
from taskflower.types import ann
|
from taskflower.types import ann
|
||||||
from taskflower.types.either import Either, Left, Right
|
from taskflower.types.either import Either, Left, Right
|
||||||
from taskflower.types.option import Option
|
from taskflower.types.option import Option
|
||||||
|
from taskflower.util.time import from_sophont_provided_data
|
||||||
|
|
||||||
|
def is_valid_iana(_: Form, field: Field):
|
||||||
|
if not isinstance(field.data, str): # pyright:ignore[reportAny]
|
||||||
|
raise ValidationError('Incorrect type')
|
||||||
|
|
||||||
|
try:
|
||||||
|
ZoneInfo(field.data) # pyright:ignore[reportUnusedCallResult]
|
||||||
|
except Exception:
|
||||||
|
raise ValidationError('Invalid timezone.')
|
||||||
|
|
||||||
|
def is_valid_timestamp(form: Form, field: Field):
|
||||||
|
if not isinstance(field.data, str): # pyright:ignore[reportAny]
|
||||||
|
raise ValidationError('Incorrect type')
|
||||||
|
|
||||||
|
if (
|
||||||
|
(not hasattr(form, 'timezone'))
|
||||||
|
or (not isinstance(tzfld:=getattr(form, 'timezone'), Field)) # pyright:ignore[reportAny]
|
||||||
|
or (not isinstance(tzdata:=tzfld.data, str)) # pyright:ignore[reportAny]
|
||||||
|
):
|
||||||
|
raise ValidationError('Invalid timezone.')
|
||||||
|
|
||||||
|
res = from_sophont_provided_data(
|
||||||
|
field.data,
|
||||||
|
tzdata
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(res, Left):
|
||||||
|
raise ValidationError(f'Parse failure: {res.val!s}')
|
||||||
|
|
||||||
def task_edit_form_for_task(
|
def task_edit_form_for_task(
|
||||||
t: Task
|
t: Task
|
||||||
|
|
@ -28,12 +59,20 @@ def task_edit_form_for_task(
|
||||||
],
|
],
|
||||||
default=t.name
|
default=t.name
|
||||||
)
|
)
|
||||||
due: DateTimeLocalField = DateTimeLocalField(
|
due: StringField = StringField(
|
||||||
'Due Date',
|
'Due Date',
|
||||||
[
|
[
|
||||||
validators.DataRequired()
|
validators.Optional(),
|
||||||
|
is_valid_timestamp
|
||||||
],
|
],
|
||||||
default=t.due # pyright:ignore[reportArgumentType]
|
default=None
|
||||||
|
)
|
||||||
|
timezone: StringField = StringField(
|
||||||
|
'Timezone, in IANA format',
|
||||||
|
[
|
||||||
|
is_valid_iana
|
||||||
|
],
|
||||||
|
default='Etc/UTC'
|
||||||
)
|
)
|
||||||
description: TextAreaField = TextAreaField(
|
description: TextAreaField = TextAreaField(
|
||||||
'Description',
|
'Description',
|
||||||
|
|
@ -47,7 +86,15 @@ def task_edit_form_for_task(
|
||||||
def edit_object(self, current_user: User, target_object: Task) -> Either[Exception, Task]:
|
def edit_object(self, current_user: User, target_object: Task) -> Either[Exception, Task]:
|
||||||
def _do_edit(tsk: Task) -> Either[Exception, Task]:
|
def _do_edit(tsk: Task) -> Either[Exception, Task]:
|
||||||
tsk.name = ann(self.name.data)
|
tsk.name = ann(self.name.data)
|
||||||
tsk.due = ann(self.due.data)
|
|
||||||
|
if self.due.data:
|
||||||
|
# We already check the validity during validation,
|
||||||
|
# so this call should always be Right
|
||||||
|
tsk.due = from_sophont_provided_data(
|
||||||
|
self.due.data,
|
||||||
|
ann(self.timezone.data)
|
||||||
|
).assert_right().val.astimezone(timezone.utc)
|
||||||
|
|
||||||
tsk.description = (
|
tsk.description = (
|
||||||
self.description.data
|
self.description.data
|
||||||
) if self.description.data else ''
|
) if self.description.data else ''
|
||||||
|
|
@ -94,12 +141,19 @@ def task_form_for_user(
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
due: DateTimeLocalField = DateTimeLocalField(
|
due: StringField = StringField(
|
||||||
'Due Date',
|
'Due Date',
|
||||||
[
|
[
|
||||||
validators.DataRequired()
|
is_valid_timestamp
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
timezone: StringField = StringField(
|
||||||
|
'Timezone, in IANA format',
|
||||||
|
[
|
||||||
|
is_valid_iana
|
||||||
|
],
|
||||||
|
default='Etc/UTC'
|
||||||
|
)
|
||||||
namespace: SelectField = SelectField(
|
namespace: SelectField = SelectField(
|
||||||
'Select a namespace',
|
'Select a namespace',
|
||||||
[
|
[
|
||||||
|
|
@ -121,6 +175,16 @@ def task_form_for_user(
|
||||||
current_user: User
|
current_user: User
|
||||||
) -> Either[Exception, Task]:
|
) -> Either[Exception, Task]:
|
||||||
if self.validate():
|
if self.validate():
|
||||||
|
due_date_parsed = from_sophont_provided_data(
|
||||||
|
ann(self.due.data),
|
||||||
|
ann(self.timezone.data)
|
||||||
|
).assert_right().val.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
print(f'Timezone is: {self.timezone.data}')
|
||||||
|
print(f'Time is {due_date_parsed.astimezone(ZoneInfo("America/Chicago"))!s}')
|
||||||
|
print(f'Time (UTC) is {due_date_parsed!s}')
|
||||||
|
print(f'Timestamp is {int(due_date_parsed.timestamp())}')
|
||||||
|
|
||||||
return Option[Namespace].encapsulate(
|
return Option[Namespace].encapsulate(
|
||||||
db.session.query(
|
db.session.query(
|
||||||
Namespace
|
Namespace
|
||||||
|
|
@ -140,13 +204,14 @@ def task_form_for_user(
|
||||||
).map(
|
).map(
|
||||||
lambda ns: Task(
|
lambda ns: Task(
|
||||||
name=self.name.data, # pyright:ignore[reportCallIssue]
|
name=self.name.data, # pyright:ignore[reportCallIssue]
|
||||||
due=self.due.data, # pyright:ignore[reportCallIssue]
|
due=due_date_parsed, # pyright:ignore[reportCallIssue]
|
||||||
description=( # pyright:ignore[reportCallIssue]
|
description=( # pyright:ignore[reportCallIssue]
|
||||||
self.description.data
|
self.description.data
|
||||||
if self.description.data
|
if self.description.data
|
||||||
else ''
|
else ''
|
||||||
),
|
),
|
||||||
namespace=ns.id # pyright:ignore[reportCallIssue]
|
namespace=ns.id, # pyright:ignore[reportCallIssue]
|
||||||
|
owner=current_user.id # pyright:ignore[reportCallIssue]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
from secrets import token_hex
|
from secrets import token_hex
|
||||||
from typing import override
|
from typing import override
|
||||||
from wtforms import SelectField
|
from wtforms import SelectField
|
||||||
|
|
@ -15,6 +15,7 @@ from taskflower.form import FormCreatesObjectWithUserAndNamespace
|
||||||
from taskflower.types import ann
|
from taskflower.types import ann
|
||||||
from taskflower.types.either import Either, Left, Right, gather_successes
|
from taskflower.types.either import Either, Left, Right, gather_successes
|
||||||
from taskflower.types.option import Option
|
from taskflower.types.option import Option
|
||||||
|
from taskflower.util.time import now
|
||||||
|
|
||||||
|
|
||||||
def gen_namespace_invite(
|
def gen_namespace_invite(
|
||||||
|
|
@ -37,7 +38,7 @@ def gen_namespace_invite(
|
||||||
code=token_hex(16), # pyright:ignore[reportCallIssue]
|
code=token_hex(16), # pyright:ignore[reportCallIssue]
|
||||||
created_by=creator.id, # pyright:ignore[reportCallIssue]
|
created_by=creator.id, # pyright:ignore[reportCallIssue]
|
||||||
for_role=role.id, # pyright:ignore[reportCallIssue]
|
for_role=role.id, # pyright:ignore[reportCallIssue]
|
||||||
expires=datetime.now() + timedelta(days=7) # pyright:ignore[reportCallIssue]
|
expires=now() + timedelta(days=7) # pyright:ignore[reportCallIssue]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Self
|
from typing import Self
|
||||||
|
|
||||||
import humanize
|
import humanize
|
||||||
|
|
@ -10,11 +10,13 @@ from taskflower.auth.permission.lookups import get_user_perms_on_task
|
||||||
from taskflower.db.model.task import Task
|
from taskflower.db.model.task import Task
|
||||||
from taskflower.db.model.user import User
|
from taskflower.db.model.user import User
|
||||||
from taskflower.types.either import Either
|
from taskflower.types.either import Either
|
||||||
|
from taskflower.util.time import ensure_timezone_aware, now
|
||||||
|
|
||||||
def _due_str(due: datetime) -> str:
|
def _due_str(due: datetime) -> str:
|
||||||
now = datetime.now()
|
due = ensure_timezone_aware(due)
|
||||||
delta = datetime.now() - due
|
cur_dt = now()
|
||||||
if now > due:
|
delta = now() - due
|
||||||
|
if cur_dt > due:
|
||||||
return humanize.naturaldelta(
|
return humanize.naturaldelta(
|
||||||
delta
|
delta
|
||||||
) + ' ago'
|
) + ' ago'
|
||||||
|
|
@ -65,7 +67,7 @@ class TaskForUser:
|
||||||
tsk.id,
|
tsk.id,
|
||||||
tsk.name,
|
tsk.name,
|
||||||
tsk.description,
|
tsk.description,
|
||||||
tsk.due,
|
tsk.due.replace(tzinfo=timezone.utc),
|
||||||
_due_str(tsk.due),
|
_due_str(tsk.due),
|
||||||
tsk.created,
|
tsk.created,
|
||||||
tsk.complete,
|
tsk.complete,
|
||||||
|
|
|
||||||
173
src/taskflower/static/time-utils.js
Normal file
173
src/taskflower/static/time-utils.js
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
const months = [
|
||||||
|
'January',
|
||||||
|
'February',
|
||||||
|
'March',
|
||||||
|
'April',
|
||||||
|
'May',
|
||||||
|
'June',
|
||||||
|
'July',
|
||||||
|
'August',
|
||||||
|
'September',
|
||||||
|
'October',
|
||||||
|
'November',
|
||||||
|
'December'
|
||||||
|
]
|
||||||
|
|
||||||
|
const numbers = [
|
||||||
|
'zero',
|
||||||
|
'one',
|
||||||
|
'two',
|
||||||
|
'three',
|
||||||
|
'four',
|
||||||
|
'five',
|
||||||
|
'six',
|
||||||
|
'seven',
|
||||||
|
'eight',
|
||||||
|
'nine',
|
||||||
|
]
|
||||||
|
|
||||||
|
function day_suffix(day){
|
||||||
|
switch(day){
|
||||||
|
case 1:
|
||||||
|
return 'st'
|
||||||
|
case 2:
|
||||||
|
return 'nd'
|
||||||
|
case 3:
|
||||||
|
return 'rd'
|
||||||
|
default:
|
||||||
|
if((day % 100) > 20){
|
||||||
|
return day_suffix(day % 10)
|
||||||
|
}else{
|
||||||
|
return 'th'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _pad(v){
|
||||||
|
return `${v}`.padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
function long_datetime(date){
|
||||||
|
return `${months[date.getMonth()]} ${date.getDay()}${day_suffix(date.getDay())}, ${date.getFullYear()} at ${date.getHours()}:${_pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function short_datetime(date){
|
||||||
|
return `${months[date.getMonth()]} ${date.getDay()}${day_suffix(date.getDay())} at ${date.getHours()}:${_pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_abstimes(){
|
||||||
|
Array.from(document.querySelectorAll(
|
||||||
|
'.render-timestamp'
|
||||||
|
)).forEach(
|
||||||
|
(el) => {
|
||||||
|
const el_time = new Date(Number(el.dataset.timestamp)*1000)
|
||||||
|
el.textContent = `${long_datetime(el_time)}`
|
||||||
|
el.classList.remove('render-timestamp') // Timestamps only need to be rendered to the local timezone once.
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function textize_number(num){
|
||||||
|
switch(num){
|
||||||
|
case 1:
|
||||||
|
return 'one'
|
||||||
|
case 2:
|
||||||
|
return 'two'
|
||||||
|
case 3:
|
||||||
|
return 'three'
|
||||||
|
case 4:
|
||||||
|
return 'four'
|
||||||
|
case 5:
|
||||||
|
return 'five'
|
||||||
|
case 6:
|
||||||
|
return 'six'
|
||||||
|
case 7:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sophontize(delta, literal){
|
||||||
|
const isFuture = delta > 0
|
||||||
|
const absDelta = Math.floor(Math.abs(delta) / 1000)
|
||||||
|
const seconds = absDelta % 60
|
||||||
|
const minutes = Math.floor(absDelta/60) % 60
|
||||||
|
const hours = Math.floor(absDelta/(60*60)) % 24
|
||||||
|
const days = Math.floor(absDelta/(60*60*24))
|
||||||
|
const weeks = Math.floor(days/7)
|
||||||
|
|
||||||
|
function _cap(v){
|
||||||
|
return v.charAt(0).toUpperCase() + v.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function _relative(v){
|
||||||
|
if(isFuture){
|
||||||
|
return `In ${v}`
|
||||||
|
}else{
|
||||||
|
return _cap(`${v} ago`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _plur(v){
|
||||||
|
if(v == 1){
|
||||||
|
return ''
|
||||||
|
}else{
|
||||||
|
return 's'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _num(v){
|
||||||
|
if(v < 3){
|
||||||
|
return numbers[v]
|
||||||
|
}else{
|
||||||
|
return v.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(days > 365){
|
||||||
|
return long_datetime(literal)
|
||||||
|
}else if(days > 28){
|
||||||
|
return short_datetime(literal)
|
||||||
|
}else if(weeks >= 1){
|
||||||
|
return _relative(`${_num(weeks)} week${_plur(weeks)}`)
|
||||||
|
}else if(days >= 1){
|
||||||
|
return _relative(`${_num(days)} day${_plur(days)}`)
|
||||||
|
}else if(hours >= 1){
|
||||||
|
return _relative(`${_num(hours)} hour${_plur(hours)}`)
|
||||||
|
}else if(minutes >= 5){
|
||||||
|
return _relative(`${_num(minutes)} minute${_plur(minutes)}`)
|
||||||
|
}else if(seconds >= 1 && isFuture){
|
||||||
|
return _relative(`${_pad(minutes)}:${_pad(seconds)}`)
|
||||||
|
}else if(minutes >= 1){
|
||||||
|
return _relative(`${_num(minutes)} minute${_plur(minutes)}`)
|
||||||
|
}else if(seconds >= 1){
|
||||||
|
return _relative(`${_num(seconds)} second${_plur(seconds)}`)
|
||||||
|
}else{
|
||||||
|
return 'Now'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function update_reltimes(){
|
||||||
|
Array.from(
|
||||||
|
document.querySelectorAll(
|
||||||
|
'.render-delta'
|
||||||
|
)
|
||||||
|
).forEach((el) => {
|
||||||
|
const time = new Date(Number(el.dataset.timestamp)*1000)
|
||||||
|
const delta = time - Date.now()
|
||||||
|
el.textContent = sophontize(delta, time)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function check_and_fill_timezone(){
|
||||||
|
const el = document.getElementById('timezone')
|
||||||
|
if(el){
|
||||||
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
console.log(`User timezone detected as ${tz}. Filling it in automatically.`)
|
||||||
|
el.value = tz
|
||||||
|
document.getElementById('tz-container').style = 'display: none;'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render_abstimes()
|
||||||
|
update_reltimes()
|
||||||
|
check_and_fill_timezone()
|
||||||
|
setInterval(update_reltimes, 1000)
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
<script type="text/javascript" src="/static/time-utils.js" defer></script>
|
||||||
<title>{% block raw_title %}{% endblock %}</title>
|
<title>{% block raw_title %}{% endblock %}</title>
|
||||||
{% block head_extras %}{% endblock %}
|
{% block head_extras %}{% endblock %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
{{ render_field(form.name) }}
|
{{ render_field(form.name) }}
|
||||||
{{ render_field(form.due) }}
|
{{ render_field(form.due) }}
|
||||||
{{ render_field(form.description) }}
|
{{ render_field(form.description) }}
|
||||||
|
<div id="tz-container">{{ render_field(form.timezone) }}</div>
|
||||||
{{ render_field(form.namespace) }}
|
{{ render_field(form.namespace) }}
|
||||||
</dl>
|
</dl>
|
||||||
<p><button id="submit-form" class="icon-btn green" type="submit">{{icon('add')|safe}}CREATE TASK</button></p>
|
<p><button id="submit-form" class="icon-btn green" type="submit">{{icon('add')|safe}}CREATE TASK</button></p>
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
<label for="{{ tlist_cid('check-inner', task.id, list_id) }}">{{ task.name }}</label>
|
<label for="{{ tlist_cid('check-inner', task.id, list_id) }}">{{ task.name }}</label>
|
||||||
</td>
|
</td>
|
||||||
<td class="task-due" id="{{ tlist_cid('task-due', task.id, list_id) }}">
|
<td class="task-due" id="{{ tlist_cid('task-due', task.id, list_id) }}">
|
||||||
<p>{{ task.due_rel }}</p>
|
<p>{{ reltime(task.due)|safe }}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr></tr> <!-- placeholder for CSS styling -->
|
<tr></tr> <!-- placeholder for CSS styling -->
|
||||||
|
|
@ -61,8 +61,8 @@
|
||||||
</div>
|
</div>
|
||||||
<p class="small-details">
|
<p class="small-details">
|
||||||
Task ID: {{ task.id }}
|
Task ID: {{ task.id }}
|
||||||
<br/>Created: {{ task.created }} in namespace {{ task.namespace_id }}
|
<br/>Created: {{ abstime(task.created)|safe }} in namespace {{ task.namespace_id }}
|
||||||
<br/>Due: {{ task.due }}
|
<br/>Due: {{ abstime(task.due)|safe }}
|
||||||
</p>
|
</p>
|
||||||
<hr/>
|
<hr/>
|
||||||
<div class="main-description">{{ render_as_markdown(task.description)|safe }}</div>
|
<div class="main-description">{{ render_as_markdown(task.description)|safe }}</div>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
{{ render_field(form.name) }}
|
{{ render_field(form.name) }}
|
||||||
{{ render_field(form.due) }}
|
{{ render_field(form.due) }}
|
||||||
{{ render_field(form.description) }}
|
{{ render_field(form.description) }}
|
||||||
|
<div id="tz-container">{{ render_field(form.timezone) }}</div>
|
||||||
</dl>
|
</dl>
|
||||||
<button class="icon-btn green" type="submit" id="submit-form">{{icon('add')|safe}}Edit Task</button>
|
<button class="icon-btn green" type="submit" id="submit-form">{{icon('add')|safe}}Edit Task</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
from multiprocessing.synchronize import BoundedSemaphore
|
from multiprocessing.synchronize import BoundedSemaphore
|
||||||
import os
|
import os
|
||||||
|
|
@ -18,6 +18,7 @@ from taskflower.auth import password_breach_count
|
||||||
from taskflower.db import db
|
from taskflower.db import db
|
||||||
from taskflower.db.model.hibp import PwnedPassword
|
from taskflower.db.model.hibp import PwnedPassword
|
||||||
from taskflower.types.either import Either, Left, Right, reduce_either
|
from taskflower.types.either import Either, Left, Right, reduce_either
|
||||||
|
from taskflower.util.time import now
|
||||||
|
|
||||||
API_BASE = 'https://api.pwnedpasswords.com/range'
|
API_BASE = 'https://api.pwnedpasswords.com/range'
|
||||||
HASH_CHARS = '0123456789ABCDEF'
|
HASH_CHARS = '0123456789ABCDEF'
|
||||||
|
|
@ -300,7 +301,7 @@ def hibp_to_database(
|
||||||
print(f'[main] Error while clearing database: {e}. Cancelling.')
|
print(f'[main] Error while clearing database: {e}. Cancelling.')
|
||||||
return
|
return
|
||||||
|
|
||||||
op_start = datetime.now()
|
op_start = now()
|
||||||
|
|
||||||
print(f'[main] Started reading data at {op_start}')
|
print(f'[main] Started reading data at {op_start}')
|
||||||
|
|
||||||
|
|
@ -328,7 +329,7 @@ def hibp_to_database(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'[{fname}] Error while processing file: {e}')
|
print(f'[{fname}] Error while processing file: {e}')
|
||||||
finally:
|
finally:
|
||||||
cur_time = datetime.now()
|
cur_time = now()
|
||||||
elapsed = cur_time - op_start
|
elapsed = cur_time - op_start
|
||||||
pct_done = (dex+1)/len(fnames_to_read)
|
pct_done = (dex+1)/len(fnames_to_read)
|
||||||
pct_left = 1.0 - pct_done
|
pct_left = 1.0 - pct_done
|
||||||
|
|
|
||||||
0
src/taskflower/util/__init__.py
Normal file
0
src/taskflower/util/__init__.py
Normal file
689
src/taskflower/util/time.py
Normal file
689
src/taskflower/util/time.py
Normal file
|
|
@ -0,0 +1,689 @@
|
||||||
|
from datetime import datetime, time, timedelta, timezone
|
||||||
|
import re
|
||||||
|
import humanize
|
||||||
|
import logging
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from taskflower.sanitize.markdown import SafeHTML
|
||||||
|
from taskflower.types.either import Either, Left, Right
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TimezoneAwareDatetime = datetime
|
||||||
|
TAD = TimezoneAwareDatetime
|
||||||
|
TimezoneNaiveDatetime = datetime
|
||||||
|
TND = TimezoneNaiveDatetime
|
||||||
|
|
||||||
|
def is_timezone_aware(dt: TAD|TND) -> bool:
|
||||||
|
return (
|
||||||
|
(dt.tzinfo is not None)
|
||||||
|
and (dt.tzinfo.utcoffset(dt) is not None)
|
||||||
|
)
|
||||||
|
|
||||||
|
def ensure_timezone_aware(dt: TAD|TND) -> TAD:
|
||||||
|
''' Ensures that ``dt`` is timezone-aware, by assuming that timezone-naive
|
||||||
|
``datetime``s are in UTC.
|
||||||
|
'''
|
||||||
|
if not is_timezone_aware(dt):
|
||||||
|
log.debug(f'Non-timezone-aware datetime object {dt!s} was passed to `ensure_timezone_aware()`. It will be assumed to be UTC.')
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
else:
|
||||||
|
return dt
|
||||||
|
|
||||||
|
def now():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
def get_reltime(until: TAD|TND) -> timedelta:
|
||||||
|
return ensure_timezone_aware(until) - now()
|
||||||
|
|
||||||
|
def from_local(local: TND, tz: str = 'Etc/UTC') -> TAD:
|
||||||
|
''' Converts a datetime received in a users's local time to one in UTC.
|
||||||
|
If we don't get any timezone info, assume it's already UTC.
|
||||||
|
|
||||||
|
If ``tz`` is invalid, it is also assumed to be UTC.
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
local_tz = ZoneInfo(tz)
|
||||||
|
except Exception:
|
||||||
|
local_tz = timezone.utc
|
||||||
|
return local.replace(
|
||||||
|
tzinfo=local_tz
|
||||||
|
).astimezone(
|
||||||
|
timezone.utc
|
||||||
|
)
|
||||||
|
|
||||||
|
def render_reltime(dt: TAD) -> SafeHTML:
|
||||||
|
dt_aware = ensure_timezone_aware(dt)
|
||||||
|
humanized = humanize.naturaltime(dt_aware)
|
||||||
|
|
||||||
|
# Client-side javascript is responsible for updating the timestamp on the
|
||||||
|
# user's end, but we provide a sensible default for non-JS browsers.
|
||||||
|
return f'<span class="render-delta" data-timestamp="{int(dt_aware.astimezone(timezone.utc).timestamp())}">{humanized}</span>'
|
||||||
|
|
||||||
|
def render_abstime(dt: TAD) -> SafeHTML:
|
||||||
|
dt_aware = ensure_timezone_aware(dt)
|
||||||
|
user_text = dt_aware.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
# Client-side javascript is responsible for doing timezone conversions on
|
||||||
|
# the user's end, but we provide a sensible default for non-JS browsers.
|
||||||
|
return f'<span class="render-timestamp" data-timestamp="{int(dt_aware.astimezone(timezone.utc).timestamp())}">{user_text} UTC</span>'
|
||||||
|
|
||||||
|
WEEKDAYS=[
|
||||||
|
'MONDAY',
|
||||||
|
'TUESDAY',
|
||||||
|
'WEDNESDAY',
|
||||||
|
'THURSDAY',
|
||||||
|
'FRIDAY',
|
||||||
|
'SATURDAY',
|
||||||
|
'SUNDAY'
|
||||||
|
]
|
||||||
|
|
||||||
|
WEEKDAYS_RE = '|'.join(WEEKDAYS)
|
||||||
|
|
||||||
|
TIMESPANS=[
|
||||||
|
'SECOND',
|
||||||
|
'MINUTE',
|
||||||
|
'HOUR',
|
||||||
|
'DAY',
|
||||||
|
'WEEK',
|
||||||
|
'MONTH',
|
||||||
|
'YEAR',
|
||||||
|
'DECADE',
|
||||||
|
'CENTURY',
|
||||||
|
'MILLENNIUM'
|
||||||
|
]
|
||||||
|
|
||||||
|
TIMESPANS_PLURAL=[
|
||||||
|
'SECONDS',
|
||||||
|
'MINUTES',
|
||||||
|
'HOURS',
|
||||||
|
'DAYS',
|
||||||
|
'WEEKS',
|
||||||
|
'MONTHS',
|
||||||
|
'YEARS',
|
||||||
|
'DECADES',
|
||||||
|
'CENTURIES',
|
||||||
|
'MILLENNIA'
|
||||||
|
]
|
||||||
|
|
||||||
|
TIMESPANS_SINGULAR_RE = '|'.join(TIMESPANS)
|
||||||
|
TIMESPANS_RE = '|'.join(TIMESPANS) + '|' + '|'.join(TIMESPANS_PLURAL)
|
||||||
|
|
||||||
|
MONTHS=[
|
||||||
|
'JANUARY',
|
||||||
|
'FEBRUARY',
|
||||||
|
'MARCH',
|
||||||
|
'APRIL',
|
||||||
|
'MAY',
|
||||||
|
'JUNE',
|
||||||
|
'JULY',
|
||||||
|
'AUGUST',
|
||||||
|
'SEPTEMBER',
|
||||||
|
'OCTOBER',
|
||||||
|
'NOVEMBER',
|
||||||
|
'DECEMBER',
|
||||||
|
]
|
||||||
|
|
||||||
|
MONTHS_RE = '|'.join(MONTHS)
|
||||||
|
|
||||||
|
TIME_RE = (
|
||||||
|
'(?P<time_normal>(?P<hours>[0-9]{1,2})'
|
||||||
|
+ '(?::(?P<minutes>[0-9]{2}))?'
|
||||||
|
+ '(?::(?P<seconds>[0-9]{2}))?'
|
||||||
|
+ ' ?(?P<ampm>AM|PM)?)'
|
||||||
|
+ '|(?P<time_special>NOON|MIDNIGHT)'
|
||||||
|
)
|
||||||
|
|
||||||
|
STRICT_TIME_RE = (
|
||||||
|
'(?P<time_normal>(?P<hours>[0-9]{1,2})'
|
||||||
|
+ '(?::(?P<minutes>[0-9]{2}))'
|
||||||
|
+ '(?::(?P<seconds>[0-9]{2}))?'
|
||||||
|
+ ' ?(?P<ampm>AM|PM)?)'
|
||||||
|
+ '|(?P<time_special>NOON|MIDNIGHT)'
|
||||||
|
)
|
||||||
|
|
||||||
|
RELATIVE_TIMESTAMP_RE = re.compile(
|
||||||
|
'^('
|
||||||
|
+ '(?P<immediate>YESTERDAY|TODAY|TOMORROW)'
|
||||||
|
+ '|(?P<lastnext_group>'
|
||||||
|
+ f'(?P<lastnext_weekday>{WEEKDAYS_RE})?'
|
||||||
|
+ '(?P<lastnext>LAST |THIS |NEXT )?'
|
||||||
|
+ '(?P<lastnext_target>'
|
||||||
|
+ WEEKDAYS_RE
|
||||||
|
+ '|' + TIMESPANS_SINGULAR_RE
|
||||||
|
+ ')'
|
||||||
|
+ ')|(?P<inago_group>'
|
||||||
|
+ '(?P<in>IN )?'
|
||||||
|
+ '(?P<inago_count>[0-9]+) '
|
||||||
|
+ '(?P<inago_target>'
|
||||||
|
+ TIMESPANS_RE
|
||||||
|
+ ')'
|
||||||
|
+ '(?P<ago> AGO)?'
|
||||||
|
+ f') ?(?P<time_group>{TIME_RE})?'
|
||||||
|
+ ')$'
|
||||||
|
)
|
||||||
|
|
||||||
|
def days_per_month(month: str|int, year: int) -> int:
|
||||||
|
if isinstance(month, int):
|
||||||
|
month = MONTHS[month]
|
||||||
|
|
||||||
|
month = month.upper()
|
||||||
|
|
||||||
|
if month not in MONTHS:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if month == 'FEBRUARY':
|
||||||
|
return (
|
||||||
|
29 if (year % 4 == 0) else 28
|
||||||
|
)
|
||||||
|
|
||||||
|
if month in [
|
||||||
|
'JANUARY',
|
||||||
|
'MARCH',
|
||||||
|
'MAY',
|
||||||
|
'JULY',
|
||||||
|
'AUGUST',
|
||||||
|
'OCTOBER',
|
||||||
|
'DECEMBER'
|
||||||
|
]:
|
||||||
|
return 31
|
||||||
|
else:
|
||||||
|
return 30
|
||||||
|
|
||||||
|
def try_parse_relative_timestamp(
|
||||||
|
data: str,
|
||||||
|
user_tz: ZoneInfo|timezone
|
||||||
|
) -> Either[Exception, TAD]:
|
||||||
|
dnorm = re.sub(
|
||||||
|
'\\s+',
|
||||||
|
' ',
|
||||||
|
data # ensure any whitespace is reduced to single spaces
|
||||||
|
).upper().strip().replace(',', '')
|
||||||
|
|
||||||
|
cur_dt = now().astimezone(user_tz)
|
||||||
|
|
||||||
|
err: Left[Exception, TAD] = Left(
|
||||||
|
SyntaxError('Unable to parse relative time-string')
|
||||||
|
)
|
||||||
|
|
||||||
|
m = re.match(
|
||||||
|
RELATIVE_TIMESTAMP_RE,
|
||||||
|
dnorm
|
||||||
|
)
|
||||||
|
|
||||||
|
if not m:
|
||||||
|
return err
|
||||||
|
|
||||||
|
time_to_use = time(23, 59, 59)
|
||||||
|
|
||||||
|
if m['time_group']:
|
||||||
|
if m['time_normal']:
|
||||||
|
hours = int(m['hours'])
|
||||||
|
minutes = int(m['minutes']) if m['minutes'] else 59
|
||||||
|
seconds = int(m['seconds']) if m['seconds'] else 59
|
||||||
|
if hours > 23 or minutes > 59 or seconds > 59:
|
||||||
|
return err
|
||||||
|
if m['ampm'] == 'AM' and hours > 12:
|
||||||
|
return err
|
||||||
|
if m['ampm'] == 'PM' and hours < 12:
|
||||||
|
hours += 12
|
||||||
|
if m['ampm'] == 'AM' and hours == 12:
|
||||||
|
hours = 0
|
||||||
|
time_to_use = time(hours, minutes, seconds)
|
||||||
|
elif m['time_special']:
|
||||||
|
match m['time_special']:
|
||||||
|
case 'NOON':
|
||||||
|
time_to_use = time(12, 00, 00)
|
||||||
|
case 'MIDNIGHT':
|
||||||
|
time_to_use = time(00, 00, 00)
|
||||||
|
case _:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _from_date(dt: TAD, tz: ZoneInfo|timezone):
|
||||||
|
adj = dt.astimezone(tz)
|
||||||
|
return Right[Exception, datetime](datetime(
|
||||||
|
adj.year,
|
||||||
|
adj.month,
|
||||||
|
adj.day,
|
||||||
|
time_to_use.hour,
|
||||||
|
time_to_use.minute,
|
||||||
|
time_to_use.second,
|
||||||
|
0,
|
||||||
|
tz
|
||||||
|
))
|
||||||
|
def _end_of_month(year: int, month: int, tz: ZoneInfo|timezone):
|
||||||
|
return Right[Exception, datetime](datetime(
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
days_per_month(MONTHS[month], year),
|
||||||
|
time_to_use.hour,
|
||||||
|
time_to_use.minute,
|
||||||
|
time_to_use.second,
|
||||||
|
0,
|
||||||
|
tz
|
||||||
|
))
|
||||||
|
|
||||||
|
if m['immediate']:
|
||||||
|
match m['immediate']:
|
||||||
|
case 'YESTERDAY':
|
||||||
|
yesterday = cur_dt - timedelta(days=1)
|
||||||
|
return _from_date(yesterday, user_tz)
|
||||||
|
case 'TODAY':
|
||||||
|
return _from_date(cur_dt, user_tz)
|
||||||
|
case 'TOMORROW':
|
||||||
|
tomorrow = cur_dt + timedelta(days=1)
|
||||||
|
return _from_date(tomorrow, user_tz)
|
||||||
|
case _:
|
||||||
|
return err
|
||||||
|
|
||||||
|
if m['lastnext_group']:
|
||||||
|
target = m['lastnext_target']
|
||||||
|
lastnext = m['lastnext']
|
||||||
|
|
||||||
|
if target in WEEKDAYS:
|
||||||
|
tgt_weekday = WEEKDAYS.index(target)
|
||||||
|
cur_weekday = cur_dt.weekday()
|
||||||
|
|
||||||
|
if m['lastnext_weekday']:
|
||||||
|
# no, "tuesday next wednesday" is NOT a valid timestring.
|
||||||
|
# you absolute BUFFOON.
|
||||||
|
return err
|
||||||
|
|
||||||
|
if lastnext == 'LAST ':
|
||||||
|
days_delta = cur_weekday - tgt_weekday
|
||||||
|
if days_delta <= 0:
|
||||||
|
# if days_delta == 0, that means e.g. today is wednesday and
|
||||||
|
# the query is 'last wednesday', so we want the delta to be
|
||||||
|
# -7 days in that case. if it's negative, that means e.g.
|
||||||
|
# today is wednesday and the query is `last saturday`, so we
|
||||||
|
# want to wrap around the weekdays.
|
||||||
|
days_delta += 7
|
||||||
|
days_delta *= -1
|
||||||
|
elif lastnext == 'NEXT ':
|
||||||
|
# "Next X" is kind of ambiguous. We default to the following:
|
||||||
|
# - Advance to the next Sunday
|
||||||
|
# - Advance to the next X
|
||||||
|
# - If the delta is 3 days or less, (e.g. Next Monday on a
|
||||||
|
# Friday), then add an extra week.
|
||||||
|
to_next_week_boundary = 7 - cur_weekday
|
||||||
|
days_delta = to_next_week_boundary + tgt_weekday
|
||||||
|
if days_delta <= 3:
|
||||||
|
days_delta += 7
|
||||||
|
elif lastnext == 'THIS ':
|
||||||
|
# "This X" means "the X of this week." can be in the future or
|
||||||
|
# the past.
|
||||||
|
days_delta = tgt_weekday - cur_weekday
|
||||||
|
else:
|
||||||
|
# Just "X" means "the soonest X after today."
|
||||||
|
days_delta = tgt_weekday - cur_weekday
|
||||||
|
if days_delta <= 0:
|
||||||
|
days_delta += 7
|
||||||
|
|
||||||
|
return _from_date(
|
||||||
|
cur_dt + timedelta(days=days_delta),
|
||||||
|
user_tz
|
||||||
|
)
|
||||||
|
elif target in MONTHS:
|
||||||
|
tgt_month = MONTHS.index(target) + 1 # datetime months are 1-indexed
|
||||||
|
cur_month = cur_dt.month
|
||||||
|
match lastnext:
|
||||||
|
case 'LAST ':
|
||||||
|
if cur_month > tgt_month:
|
||||||
|
return _end_of_month(
|
||||||
|
cur_dt.year,
|
||||||
|
tgt_month,
|
||||||
|
user_tz
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return _end_of_month(
|
||||||
|
cur_dt.year-1,
|
||||||
|
tgt_month,
|
||||||
|
user_tz
|
||||||
|
)
|
||||||
|
case 'THIS ':
|
||||||
|
return _end_of_month(
|
||||||
|
cur_dt.year,
|
||||||
|
tgt_month,
|
||||||
|
user_tz
|
||||||
|
)
|
||||||
|
case 'NEXT ':
|
||||||
|
return _end_of_month(
|
||||||
|
cur_dt.year+1,
|
||||||
|
tgt_month,
|
||||||
|
user_tz
|
||||||
|
)
|
||||||
|
case _:
|
||||||
|
if cur_month > tgt_month:
|
||||||
|
return _end_of_month(
|
||||||
|
cur_dt.year+1,
|
||||||
|
tgt_month,
|
||||||
|
user_tz
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return _end_of_month(
|
||||||
|
cur_dt.year,
|
||||||
|
tgt_month,
|
||||||
|
user_tz
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
m['inago_group'] and
|
||||||
|
(
|
||||||
|
m['inago_target'] in TIMESPANS
|
||||||
|
or m['inago_target'] in TIMESPANS_PLURAL
|
||||||
|
)
|
||||||
|
) or (
|
||||||
|
m['lastnext_group'] and
|
||||||
|
(
|
||||||
|
m['lastnext_target'] in TIMESPANS
|
||||||
|
or m['lastnext_target'] in TIMESPANS_PLURAL
|
||||||
|
)
|
||||||
|
):
|
||||||
|
target = (
|
||||||
|
(
|
||||||
|
m['inago_target']
|
||||||
|
) if m['inago_target'] in TIMESPANS else (
|
||||||
|
# Singularize timespans before continuing
|
||||||
|
TIMESPANS[TIMESPANS_PLURAL.index(m['inago_target'])]
|
||||||
|
) if m['inago_target'] in TIMESPANS_PLURAL else (
|
||||||
|
m['lastnext_target']
|
||||||
|
) if m['lastnext_target'] in TIMESPANS else (
|
||||||
|
TIMESPANS[TIMESPANS_PLURAL.index(m['lastnext_target'])]
|
||||||
|
) if m['lastnext_target'] in TIMESPANS_PLURAL else (
|
||||||
|
None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(target, str):
|
||||||
|
return err
|
||||||
|
|
||||||
|
delta_unit = 0
|
||||||
|
|
||||||
|
if m['lastnext_group']:
|
||||||
|
delta_unit = (
|
||||||
|
1 if m['lastnext'] == 'NEXT ' else
|
||||||
|
-1 if m['lastnext'] == 'LAST ' else
|
||||||
|
0
|
||||||
|
)
|
||||||
|
elif m['inago_group']:
|
||||||
|
try:
|
||||||
|
delta_unit = (
|
||||||
|
(-1 * int(m['inago_count'])) if m['ago']
|
||||||
|
else int(m['inago_count'])
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return Left(e)
|
||||||
|
|
||||||
|
match target:
|
||||||
|
case 'SECOND':
|
||||||
|
return Right(cur_dt + timedelta(
|
||||||
|
seconds=delta_unit
|
||||||
|
))
|
||||||
|
case 'MINUTE':
|
||||||
|
return Right((cur_dt + timedelta(
|
||||||
|
minutes=delta_unit
|
||||||
|
)).replace(
|
||||||
|
second=59
|
||||||
|
))
|
||||||
|
case 'HOUR':
|
||||||
|
return Right((cur_dt + timedelta(
|
||||||
|
hours=delta_unit
|
||||||
|
)).replace(
|
||||||
|
minute=59,
|
||||||
|
second=59
|
||||||
|
))
|
||||||
|
case 'DAY':
|
||||||
|
return _from_date((cur_dt + timedelta(
|
||||||
|
days=delta_unit
|
||||||
|
)), user_tz)
|
||||||
|
case 'WEEK':
|
||||||
|
# default "end of week" is friday.
|
||||||
|
days_until_friday = WEEKDAYS.index('FRIDAY') - cur_dt.weekday()
|
||||||
|
if days_until_friday < 0:
|
||||||
|
days_until_friday += 7
|
||||||
|
days_delta = days_until_friday + 7*delta_unit
|
||||||
|
return _from_date(cur_dt + timedelta(
|
||||||
|
days=days_delta
|
||||||
|
), user_tz)
|
||||||
|
case 'MONTH':
|
||||||
|
final_month = ((cur_dt.month-1) + delta_unit) % 12 + 1
|
||||||
|
final_year = cur_dt.year + ((cur_dt.month + delta_unit) // 12)
|
||||||
|
return _from_date(cur_dt.replace(
|
||||||
|
year=final_year,
|
||||||
|
month=final_month,
|
||||||
|
day=days_per_month(MONTHS[final_month-1], final_year)
|
||||||
|
), user_tz)
|
||||||
|
case 'YEAR':
|
||||||
|
return _from_date(cur_dt.replace(
|
||||||
|
year=cur_dt.year + delta_unit,
|
||||||
|
month=12,
|
||||||
|
day=31
|
||||||
|
), user_tz)
|
||||||
|
case 'DECADE':
|
||||||
|
return _from_date(cur_dt.replace(
|
||||||
|
year=cur_dt.year + 10*delta_unit,
|
||||||
|
month=12,
|
||||||
|
day=31
|
||||||
|
), user_tz)
|
||||||
|
case 'CENTURY':
|
||||||
|
return _from_date(cur_dt.replace(
|
||||||
|
year=cur_dt.year + 100*delta_unit,
|
||||||
|
month=12,
|
||||||
|
day=31
|
||||||
|
), user_tz)
|
||||||
|
case 'MILLENNIUM':
|
||||||
|
return _from_date(cur_dt.replace(
|
||||||
|
year=cur_dt.year + 1000*delta_unit,
|
||||||
|
month=12,
|
||||||
|
day=31
|
||||||
|
), user_tz)
|
||||||
|
case _:
|
||||||
|
return err
|
||||||
|
|
||||||
|
return err
|
||||||
|
|
||||||
|
|
||||||
|
ABSOLUTE_TIMESTAMP_RE = re.compile(
|
||||||
|
'^'
|
||||||
|
+ '(?P<year>[0-9][0-9]+)?'
|
||||||
|
+ '(?:[-:\\/\\s]*(?P<month>[0-9]{1,2}))?'
|
||||||
|
+ '(?:[-:\\/\\s]*(?P<day>[0-9]{1,2}))?'
|
||||||
|
+ f' ?(?P<time_group>{TIME_RE})?'
|
||||||
|
+ '$'
|
||||||
|
)
|
||||||
|
|
||||||
|
JUST_TIME_RE = re.compile(
|
||||||
|
f'^{STRICT_TIME_RE}$'
|
||||||
|
)
|
||||||
|
|
||||||
|
def from_sophont_provided_data(data: str, user_tz: str = 'Etc/UTC') -> Either[Exception, TAD]:
|
||||||
|
''' Try to parse a 'sophont-friendly' time-string to a timezone-aware
|
||||||
|
datetime.
|
||||||
|
|
||||||
|
This function will process absolute timestamps ('2026-01-01 12:00') as
|
||||||
|
well as relative ones ('next thursday 11:59PM'). Relative ones will be
|
||||||
|
processed relative to the user's client-side current time, calculated as
|
||||||
|
the server-side current time converted to the user's timezone.
|
||||||
|
|
||||||
|
If a time is not provided, the time will be assumed to be 11:59PM.
|
||||||
|
|
||||||
|
Note that user-local timezones will be converted to UTC.
|
||||||
|
|
||||||
|
>>> from_sophont_provided_data('thursday', 'Etc/UTC')
|
||||||
|
Right[datetime](datetime(2025, 11, 27, 23, 59, 59, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
>>> from_sophont_provided_data('2026-01-01', 'America/Chicago')
|
||||||
|
Right[datetime](datetime(2026, 01, 02, 05, 59, 59, tzinfo=timezone.utc))
|
||||||
|
'''
|
||||||
|
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(user_tz)
|
||||||
|
except Exception as e:
|
||||||
|
return Left(e)
|
||||||
|
|
||||||
|
err: Left[Exception, TAD] = Left(
|
||||||
|
SyntaxError('Unable to parse absolute time-string.')
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
if m:=JUST_TIME_RE.match(data):
|
||||||
|
# User input *just* a time.
|
||||||
|
time_to_use = time(23, 59, 59)
|
||||||
|
if m['time_normal']:
|
||||||
|
hours = int(m['hours'])
|
||||||
|
minutes = int(m['minutes']) if m['minutes'] else 59
|
||||||
|
seconds = int(m['seconds']) if m['seconds'] else 59
|
||||||
|
if hours > 23 or minutes > 59 or seconds > 59:
|
||||||
|
return err
|
||||||
|
if m['ampm'] == 'AM' and hours > 12:
|
||||||
|
return err
|
||||||
|
if m['ampm'] == 'PM' and hours < 12:
|
||||||
|
hours += 12
|
||||||
|
if m['ampm'] == 'AM' and hours == 12:
|
||||||
|
hours = 0
|
||||||
|
time_to_use = time(hours, minutes, seconds)
|
||||||
|
elif m['time_special']:
|
||||||
|
match m['time_special']:
|
||||||
|
case 'NOON':
|
||||||
|
time_to_use = time(12, 00, 00)
|
||||||
|
case 'MIDNIGHT':
|
||||||
|
time_to_use = time(00, 00, 00)
|
||||||
|
case _:
|
||||||
|
return err
|
||||||
|
|
||||||
|
return Right(datetime.now(tz).replace(
|
||||||
|
hour=time_to_use.hour,
|
||||||
|
minute=time_to_use.minute,
|
||||||
|
second=time_to_use.second
|
||||||
|
))
|
||||||
|
|
||||||
|
if not (m:=ABSOLUTE_TIMESTAMP_RE.match(data)):
|
||||||
|
return try_parse_relative_timestamp(
|
||||||
|
data,
|
||||||
|
tz
|
||||||
|
)
|
||||||
|
|
||||||
|
time_to_use = time(23, 59, 59)
|
||||||
|
|
||||||
|
if m['time_group']:
|
||||||
|
if m['time_normal']:
|
||||||
|
hours = int(m['hours'])
|
||||||
|
minutes = int(m['minutes']) if m['minutes'] else 59
|
||||||
|
seconds = int(m['seconds']) if m['seconds'] else 59
|
||||||
|
if hours > 23 or minutes > 59 or seconds > 59:
|
||||||
|
return err
|
||||||
|
if m['ampm'] == 'AM' and hours > 12:
|
||||||
|
return err
|
||||||
|
if m['ampm'] == 'PM' and hours < 12:
|
||||||
|
hours += 12
|
||||||
|
if m['ampm'] == 'AM' and hours == 12:
|
||||||
|
hours = 0
|
||||||
|
time_to_use = time(hours, minutes, seconds)
|
||||||
|
elif m['time_special']:
|
||||||
|
match m['time_special']:
|
||||||
|
case 'NOON':
|
||||||
|
time_to_use = time(12, 00, 00)
|
||||||
|
case 'MIDNIGHT':
|
||||||
|
time_to_use = time(00, 00, 00)
|
||||||
|
case _:
|
||||||
|
pass
|
||||||
|
|
||||||
|
year = int(m['year']) if m['year'] else None
|
||||||
|
year_raw = year
|
||||||
|
if year is not None and year < 100:
|
||||||
|
year += (datetime.now(tz).year // 100)*100 # handle 2-digit years
|
||||||
|
month = int(m['month']) if m['month'] else None
|
||||||
|
day = int(m['day']) if m['day'] else None
|
||||||
|
|
||||||
|
if year and month and day:
|
||||||
|
return Right(datetime(
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day,
|
||||||
|
time_to_use.hour,
|
||||||
|
time_to_use.minute,
|
||||||
|
time_to_use.second,
|
||||||
|
tzinfo=tz
|
||||||
|
))
|
||||||
|
|
||||||
|
if year and month:
|
||||||
|
if year_raw is not None and year_raw < 12:
|
||||||
|
# User input something like "10/25".
|
||||||
|
# Assume that's actually a month and a day, not a year and a month.
|
||||||
|
cur = datetime.now(tz)
|
||||||
|
target = datetime(
|
||||||
|
cur.year,
|
||||||
|
year_raw,
|
||||||
|
month,
|
||||||
|
time_to_use.hour,
|
||||||
|
time_to_use.minute,
|
||||||
|
time_to_use.second,
|
||||||
|
tzinfo=tz
|
||||||
|
)
|
||||||
|
|
||||||
|
if target > cur:
|
||||||
|
# that month and date this year is in the future.
|
||||||
|
return Right(target)
|
||||||
|
else:
|
||||||
|
# that month and date this year would be in the past.
|
||||||
|
# assume they actually want it due on that date *next* year.
|
||||||
|
return Right(target.replace(
|
||||||
|
year=cur.year+1
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# User input something like "26/11".
|
||||||
|
# This is probably *actually* a year and a month
|
||||||
|
return Right(
|
||||||
|
datetime(
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
days_per_month(MONTHS[month-1], year),
|
||||||
|
time_to_use.hour,
|
||||||
|
time_to_use.minute,
|
||||||
|
time_to_use.second,
|
||||||
|
tzinfo=tz
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if month and day:
|
||||||
|
cur = datetime.now(tz)
|
||||||
|
target = datetime(
|
||||||
|
cur.year,
|
||||||
|
month,
|
||||||
|
day,
|
||||||
|
time_to_use.hour,
|
||||||
|
time_to_use.minute,
|
||||||
|
time_to_use.second,
|
||||||
|
tzinfo=tz
|
||||||
|
)
|
||||||
|
|
||||||
|
if target > cur:
|
||||||
|
# that month and date this year is in the future.
|
||||||
|
return Right(target)
|
||||||
|
else:
|
||||||
|
# that month and date this year would be in the past.
|
||||||
|
# assume they actually want it due on that date *next* year.
|
||||||
|
return Right(target.replace(
|
||||||
|
year=cur.year+1
|
||||||
|
))
|
||||||
|
|
||||||
|
if year:
|
||||||
|
return Right(
|
||||||
|
datetime(
|
||||||
|
year,
|
||||||
|
12,
|
||||||
|
31,
|
||||||
|
time_to_use.hour,
|
||||||
|
time_to_use.minute,
|
||||||
|
time_to_use.second,
|
||||||
|
tzinfo=tz
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# User input something bizarre. See if parse_relative() can do anything with it.
|
||||||
|
return try_parse_relative_timestamp(data, tz)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'An internal error occured while parsing the time-string {data}: {e!s}')
|
||||||
|
return Left(e)
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
from secrets import token_hex
|
from secrets import token_hex
|
||||||
from flask import Blueprint, redirect, render_template, request, url_for
|
from flask import Blueprint, redirect, render_template, request, url_for
|
||||||
from flask_login import current_user, login_required # pyright:ignore[reportMissingTypeStubs, reportUnknownVariableType]
|
from flask_login import current_user, login_required # pyright:ignore[reportMissingTypeStubs, reportUnknownVariableType]
|
||||||
|
|
@ -20,6 +20,7 @@ from taskflower.sanitize.code import SignUpCodeForUser, ZoneInviteCodeForUser
|
||||||
from taskflower.types import assert_usr
|
from taskflower.types import assert_usr
|
||||||
from taskflower.types.either import Either, Left, Right, gather_successes
|
from taskflower.types.either import Either, Left, Right, gather_successes
|
||||||
from taskflower.types.option import Option
|
from taskflower.types.option import Option
|
||||||
|
from taskflower.util.time import now
|
||||||
from taskflower.web.errors import ResponseErrorBadRequest, ResponseErrorForbidden, ResponseErrorNotFound, response_from_exception
|
from taskflower.web.errors import ResponseErrorBadRequest, ResponseErrorForbidden, ResponseErrorNotFound, response_from_exception
|
||||||
from taskflower.web.utils.request import get_next
|
from taskflower.web.utils.request import get_next
|
||||||
|
|
||||||
|
|
@ -124,8 +125,8 @@ def new_sign_up():
|
||||||
try:
|
try:
|
||||||
code = SignUpCode(
|
code = SignUpCode(
|
||||||
code=token_hex(16), # pyright:ignore[reportCallIssue]
|
code=token_hex(16), # pyright:ignore[reportCallIssue]
|
||||||
created=datetime.now(), # pyright:ignore[reportCallIssue]
|
created=now(), # pyright:ignore[reportCallIssue]
|
||||||
expires=datetime.now() + timedelta(weeks=1), # pyright:ignore[reportCallIssue]
|
expires=now() + timedelta(weeks=1), # pyright:ignore[reportCallIssue]
|
||||||
created_by=cur_usr.id # pyright:ignore[reportCallIssue]
|
created_by=cur_usr.id # pyright:ignore[reportCallIssue]
|
||||||
)
|
)
|
||||||
db.session.add(code)
|
db.session.add(code)
|
||||||
|
|
@ -240,7 +241,7 @@ def enter():
|
||||||
)
|
)
|
||||||
).flat_map(
|
).flat_map(
|
||||||
lambda code: Either.do_assert(
|
lambda code: Either.do_assert(
|
||||||
code.expires > datetime.now(),
|
code.expires > now(),
|
||||||
'Code expiration date is in the future'
|
'Code expiration date is in the future'
|
||||||
).map(
|
).map(
|
||||||
lambda _: code
|
lambda _: code
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue