Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 7 additions & 64 deletions src/onegov/core/layout.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
from __future__ import annotations


import arrow
import babel.dates
import babel.numbers
import isodate
import numbers
import sedate
Expand All @@ -13,9 +10,11 @@
from functools import lru_cache
from onegov.core import utils
from onegov.core.templates import PageTemplate
from onegov.core.utils import format_date, format_number, number_symbols
from pytz import timezone

from typing import overload, Any, TypeVar, TYPE_CHECKING

if TYPE_CHECKING:
from chameleon import PageTemplateFile
from collections.abc import Callable, Collection, Iterable, Iterator
Expand Down Expand Up @@ -154,40 +153,8 @@ def csrf_protected_url(self, url: str) -> str:
return self.request.csrf_protected_url(url)

def format_date(self, dt: datetime | date | None, format: str) -> str:
""" Takes a datetime and formats it according to local timezone and
the given format.

"""
if dt is None:
return ''

if getattr(dt, 'tzinfo', None) is not None:
dt = self.timezone.normalize(
dt.astimezone(self.timezone) # type:ignore[attr-defined]
)

locale = self.request.locale
assert locale is not None, 'Cannot format date without a locale'
if format == 'relative':
adt = arrow.get(dt)

try:
return adt.humanize(locale=locale)
except ValueError:
return adt.humanize(locale=locale.split('_')[0])

fmt = getattr(self, format + '_format')
if fmt.startswith('skeleton:'):
return babel.dates.format_skeleton(
fmt.replace('skeleton:', ''),
datetime=dt,
fuzzy=False,
locale=locale
)
elif hasattr(dt, 'hour'):
return babel.dates.format_datetime(dt, format=fmt, locale=locale)
else:
return babel.dates.format_date(dt, format=fmt, locale=locale)
fmt = getattr(self, f'{format}_format', format)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would move this part to CoreRequest as well, we should never manually specify a fmt string, it should always be one of our pre-configured format options. If one of the applications overwrites the formats in their specific layout class, you would need to instead overwrite them in their specific request class, but I'm not sure if we do overwrite them anywhere.

return format_date(dt, fmt, self.request.locale, self.timezone)

def isodate(self, date: datetime) -> str:
""" Returns the given date in the ISO 8601 format. """
Expand All @@ -200,40 +167,16 @@ def parse_isodate(self, string: str) -> datetime:
@staticmethod
@lru_cache(maxsize=8)
def number_symbols(locale: str) -> tuple[str, str]:
""" Returns the locale specific number symbols. """

return (
babel.numbers.get_decimal_symbol(locale),
babel.numbers.get_group_symbol(locale)
)
return number_symbols(locale)

def format_number(
self,
number: numbers.Number | Decimal | float | str | None,
decimal_places: int | None = None,
padding: str = ''
) -> str:
""" Takes the given numer and formats it according to locale.

If the number is an integer, the default decimal places are 0,
otherwise 2.

"""
if isinstance(number, str):
return number

if number is None:
return ''

if decimal_places is None:
if isinstance(number, numbers.Integral):
decimal_places = 0
else:
decimal_places = 2

decimal, group = self.number_symbols(self.request.locale)
result = '{{:{},.{}f}}'.format(padding, decimal_places).format(number)
return result.translate({ord(','): group, ord('.'): decimal})
return format_number(
number, decimal_places, padding, self.request.locale)

@property
def view_name(self) -> str | None:
Expand Down
103 changes: 98 additions & 5 deletions src/onegov/core/utils.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're always retrieving the locale from request it might be better to make these methods on CoreRequest. That way we can also put the date formats on CoreRequest and keep the API the same as for the methods on Layout. That way we also easily can change the date formats, without having to modify each call site.

Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from __future__ import annotations

import arrow
import babel.dates
import babel.numbers
import base64
import bleach
import pytz
from urlextract import URLExtract, CacheFileError
from bleach.linkifier import TLDS
import errno
Expand All @@ -13,6 +17,7 @@
import magic
import mimetypes
import morepath
import numbers
import operator
import os.path
import re
Expand All @@ -29,11 +34,16 @@
from itertools import groupby, islice
from markupsafe import escape
from markupsafe import Markup

from onegov.core import log
from onegov.core.custom import json
from onegov.core.errors import AlreadyLockedError
from phonenumbers import (PhoneNumberFormat, format_number,
NumberParseException, parse)
from phonenumbers import (
PhoneNumberFormat,
format_number as format_phone_number,
NumberParseException,
parse,
)
from purl import URL
from threading import Thread
from time import perf_counter
Expand All @@ -44,11 +54,13 @@
from yubico_client.yubico_exceptions import ( # type:ignore[import-untyped]
SignatureVerificationError, StatusCodeError)


from typing import overload, Any, TypeVar, TYPE_CHECKING

if TYPE_CHECKING:
from _typeshed import SupportsRichComparison
from collections.abc import Callable, Collection, Iterator
from datetime import datetime, date
from decimal import Decimal
from fs.base import FS, SubFS
from re import Match
from sqlalchemy import Column
Expand Down Expand Up @@ -1250,10 +1262,10 @@
result.append(number.replace(' ', ''))
continue

result.append(format_number(
result.append(format_phone_number(
parsed, PhoneNumberFormat.E164))

national = format_number(
national = format_phone_number(
parsed, PhoneNumberFormat.NATIONAL)
groups = national.split()
for idx in range(len(groups)):
Expand All @@ -1262,3 +1274,84 @@
result.append(partial)

return result


def format_date(
dt: datetime | date | None,
format: str,
locale: str | None = None,
timezone: pytz.BaseTzInfo | None = None,
) -> str:
"""Takes a datetime and formats it according to locale, timezone and
the given format.

"""
if dt is None:
return ''

locale = locale or 'de_CH'
timezone = timezone or pytz.timezone('Europe/Zurich')

if getattr(dt, 'tzinfo', None) is not None:
dt = timezone.normalize(
dt.astimezone(timezone) # type:ignore[attr-defined]
)

if format == 'relative':
adt = arrow.get(dt)

try:
return adt.humanize(locale=locale)
except ValueError:
return adt.humanize(locale=locale.split('_')[0])

Check warning on line 1306 in src/onegov/core/utils.py

View check run for this annotation

Codecov / codecov/patch

src/onegov/core/utils.py#L1305-L1306

Added lines #L1305 - L1306 were not covered by tests

if format.startswith('skeleton:'):
return babel.dates.format_skeleton(
format.replace('skeleton:', ''),
datetime=dt,
fuzzy=False,
locale=locale,
)
elif hasattr(dt, 'hour'):
return babel.dates.format_datetime(dt, format=format, locale=locale)
else:
return babel.dates.format_date(dt, format=format, locale=locale)


def format_number(
number: numbers.Number | Decimal | float | str | None,
decimal_places: int | None = None,
padding: str = '',
locale: str | None = 'de_CH',
) -> str:
"""Takes the given numer and formats it according to locale.

If the number is an integer, the default decimal places are 0,
otherwise 2.

"""
if isinstance(number, str):
return number

if number is None:
return ''

if decimal_places is None:
if isinstance(number, numbers.Integral):
decimal_places = 0
else:
decimal_places = 2

decimal, group = number_symbols(locale)
result = '{{:{},.{}f}}'.format(padding, decimal_places).format(number)
return result.translate({ord(','): group, ord('.'): decimal})


@lru_cache(maxsize=8)
def number_symbols(locale: str) -> tuple[str, str]:
"""Returns the locale specific number symbols."""

return (
babel.numbers.get_decimal_symbol(locale),
babel.numbers.get_group_symbol(locale),
)
25 changes: 9 additions & 16 deletions src/onegov/org/forms/newsletter.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
import transaction
from wtforms.validators import DataRequired
from onegov.core.csv import convert_excel_to_csv, CSVFile
from onegov.core.utils import format_date
from onegov.form.fields import UploadField
from onegov.org.forms.fields import HtmlField
from onegov.org.utils import extract_categories_and_subcategories
from onegov.form.validators import FileSizeLimit
from onegov.form.validators import WhitelistedMimeType
from wtforms.fields import BooleanField
from onegov.core.layout import Layout
from onegov.file.utils import name_without_extension
from onegov.form import Form
from onegov.form.fields import ChosenSelectField
Expand All @@ -29,8 +30,6 @@

from typing import Any, TYPE_CHECKING

from onegov.org.utils import extract_categories_and_subcategories

if TYPE_CHECKING:
from collections.abc import Iterable, Callable
from onegov.core.csv import DefaultRow
Expand Down Expand Up @@ -85,10 +84,6 @@ def with_news(
news: Iterable[News]
) -> type[Self]:

# FIXME: using a layout just for format_date seems bad, we should
# probably extract these functions into util modules
layout = Layout(None, request)

choices = tuple(
(
str(item.id),
Expand All @@ -97,7 +92,7 @@ def with_news(
'<div class="date">{}</div>'
).format(
item.title,
layout.format_date(item.created, 'relative')
format_date(item.created, 'relative', request.locale),
)
)
for item in news
Expand Down Expand Up @@ -149,9 +144,6 @@ def with_occurrences(
occurrences: Iterable[Occurrence]
) -> type[Self]:

# FIXME: another use of layout for format_date
layout = Layout(None, request)

choices = tuple(
(
str(item.id),
Expand All @@ -160,7 +152,11 @@ def with_occurrences(
'<div class="date">{}</div>'
).format(
item.title,
layout.format_date(item.localized_start, 'datetime')
format_date(
item.localized_start,
'dd.MM.yyyy HH:mm',
request.locale
)
)
)
for item in occurrences
Expand Down Expand Up @@ -201,9 +197,6 @@ def with_publications(
publications: Iterable[File]
) -> type[Self]:

# FIXME: another use of layout for format_date
layout = Layout(None, request)

choices = tuple(
(
str(item.id),
Expand All @@ -212,7 +205,7 @@ def with_publications(
'<div class="date">{}</div>'
).format(
name_without_extension(item.name),
layout.format_date(item.created, 'date')
format_date(item.created, 'dd.MM.yyyy', request.locale)
)
)
for item in publications
Expand Down