import lxml
import random
import re
import sedate
import string
from datetime import date, datetime, timedelta
from functools import partial
from typing import Any, Literal, TYPE_CHECKING
if TYPE_CHECKING:
from _typeshed import SupportsGetItem, SupportsRichComparison
from collections.abc import Iterable
from typing import TypeAlias, TypeVar, TypeGuard
[docs]
SupportsRichComparisonT = TypeVar(
'SupportsRichComparisonT',
bound=SupportsRichComparison
)
SupportsRichComparisonT_co = TypeVar(
'SupportsRichComparisonT_co',
bound=SupportsRichComparison,
covariant=True
)
RangeLike: TypeAlias = SupportsGetItem[int, SupportsRichComparisonT_co]
RangeTuple: TypeAlias = tuple[
SupportsRichComparisonT_co,
SupportsRichComparisonT_co
]
[docs]
INTERNAL_IMAGE_EX = re.compile(r'.*/storage/[0-9a-z]{64}')
[docs]
NUM_RANGE_RE = re.compile(r'\d+-\d+')
[docs]
DATE_RANGE_RE = re.compile(r'\d{4}-\d{2}-\d{2}:\d{4}-\d{2}-\d{2}')
[docs]
MUNICIPALITY_EX = re.compile(r"""
(?P<zipcode>[1-9]{1}[0-9]{3})
\s+
(?P<municipality>[\w\s\(\)\.\-]+)
""", re.VERBOSE)
[docs]
GROUP_CODE_EX = re.compile(r'[A-Z]{3}-?[A-Z]{3}-?[A-Z]{3}')
[docs]
def random_group_code() -> str:
# 26^9 should be a decent amount of codes to randomly chose, without
# having to check their uniqueness
raw = ''.join(
random.choice(string.ascii_uppercase) for x in range(9) # nosec B311
)
return '-'.join((raw[:3], raw[3:6], raw[-3:]))
[docs]
def is_valid_group_code(code: str) -> bool:
return True if GROUP_CODE_EX.match(code) else False
[docs]
def overlaps(
range_a: 'RangeLike[SupportsRichComparisonT]',
range_b: 'RangeLike[SupportsRichComparisonT]'
) -> bool:
return (range_b[0] <= range_a[0] <= range_b[1]) or ( # type:ignore
range_a[0] <= range_b[0] <= range_a[1]) # type:ignore[operator]
[docs]
def merge_ranges(
ranges: 'Iterable[RangeTuple[SupportsRichComparisonT]]'
) -> list['RangeTuple[SupportsRichComparisonT]']:
""" Merges the given list of ranges into a list of ranges including only
exclusive ranges. The ranges are turned into tuples to make them
hashable.
"""
ranges = sorted(ranges)
# stack of merged values
merged = [(ranges[0][0], ranges[0][1])]
for r in ranges:
if overlaps(merged[-1], r):
merged[-1] = (merged[-1][0], r[1])
else:
merged.append((r[0], r[1]))
return merged
[docs]
def num_range_decode(s: object) -> tuple[int, int] | None:
if not isinstance(s, str):
return None
if not NUM_RANGE_RE.match(s):
return None
# FIXME: Why not just use capturing groups in the regex?
age_range = tuple(int(a) for a in s.split('-'))
assert len(age_range) == 2
if age_range[0] <= age_range[1]:
return age_range
else:
return None
[docs]
def num_range_encode(a: 'RangeLike[int]') -> str:
return f'{a[0]}-{a[1]}'
[docs]
def date_range_decode(s: object) -> tuple[date, date] | None:
if not isinstance(s, str):
return None
if not DATE_RANGE_RE.match(s):
return None
s, e = s.split(':')
return date.fromisoformat(s), date.fromisoformat(e)
[docs]
def date_range_encode(d: 'RangeLike[date]') -> str:
return ':'.join((d[0].strftime('%Y-%m-%d'), d[1].strftime('%Y-%m-%d')))
[docs]
def generate_xml(payments: 'Iterable[dict[str, Any]]') -> str:
""" Creates an xml for import through ISO20022. Used for testing only. """
transactions = []
default = {
'reference': '',
'note': '',
'valdat': '2016-04-12'
}
for ix, payment in enumerate(payments):
if 'tid' not in payment:
payment['tid'] = 'T{}'.format(ix)
if payment['amount'].startswith('-'):
payment['credit'] = 'DBIT'
else:
payment['credit'] = 'CRDT'
payment['currency'] = payment['amount'][-3:]
payment['amount'] = payment['amount'].strip('-+')[:-3]
for key in default:
if key not in payment:
payment[key] = default[key]
transactions.append("""
<Ntry>
<ValDt>
<Dt>{valdat}</Dt>
</ValDt>
<NtryDtls>
<TxDtls>
<Refs>
<AcctSvcrRef>{tid}</AcctSvcrRef>
</Refs>
<Amt Ccy="{currency}">{amount}</Amt>
<CdtDbtInd>{credit}</CdtDbtInd>
<RmtInf>
<Strd>
<CdtrRefInf>
<Ref>{reference}</Ref>
</CdtrRefInf>
</Strd>
<Ustrd>{note}</Ustrd>
</RmtInf>
</TxDtls>
</NtryDtls>
</Ntry>
""".format(**payment))
return """<?xml version="1.0" encoding="UTF-8"?>
<Document>
<BkToCstmrStmt>
<Stmt>
{}
</Stmt>
</BkToCstmrStmt>
</Document>
""".format('\n'.join(transactions))
[docs]
def dates_overlap(
a: 'Iterable[RangeTuple[datetime]]',
b: 'Iterable[RangeTuple[datetime]]',
minutes_between: float = 0,
cut_end: bool = True,
alignment: Literal['day', 'week', 'month'] | None = None
) -> bool:
""" Returns true if any time tuple in the list of tuples in a overlaps
with a time tuple in b.
"""
# this can be done with an O(n log n) algorithm but since we are
# operating on a very small n the constant factors dominate and there
# are fewer constant factors in this approach:
offset = timedelta(seconds=minutes_between / 2 * 60)
ms = cut_end and timedelta(microseconds=1) or timedelta()
# make sure that 11:00 - 12:00 and 12:00 - 13:00 are not overlapping
# FIXME: What is the point of the above ms assignment?!
ms = timedelta(microseconds=1)
if alignment:
# alignment function
align = getattr(sedate, f'align_range_to_{alignment}')
# it is highliy unlikely that this will ever be anything else as this
# module is pretty much tailored for Switzerland
align = partial(align, timezone='Europe/Zurich')
for s, e in a:
for os, oe in b:
if alignment:
s, e = align(s, e)
os, oe = align(os, oe)
if sedate.overlaps(
s - offset, e + offset - ms,
os - offset, oe + offset - ms
):
return True
return False
[docs]
def is_internal_image(url: str | None) -> 'TypeGuard[str]':
return url and INTERNAL_IMAGE_EX.match(url) and True or False