first commit
This commit is contained in:
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
115
app/services/analytics.py
Normal file
115
app/services/analytics.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import extract, func
|
||||
|
||||
from ..models import Budget, Expense
|
||||
from .i18n import get_locale
|
||||
|
||||
|
||||
def _uncategorized() -> str:
|
||||
return 'Uncategorized' if get_locale() == 'en' else 'Bez kategorii'
|
||||
|
||||
|
||||
def _base_user_query(user_id: int, year: int, month: int | None = None):
|
||||
query = Expense.query.filter_by(user_id=user_id, is_deleted=False).filter(extract('year', Expense.purchase_date) == year)
|
||||
if month:
|
||||
query = query.filter(extract('month', Expense.purchase_date) == month)
|
||||
return query
|
||||
|
||||
|
||||
def monthly_summary(user_id: int, year: int, month: int):
|
||||
expenses = _base_user_query(user_id, year, month).order_by(Expense.purchase_date.desc(), Expense.id.desc()).all()
|
||||
total = sum((expense.amount for expense in expenses), Decimal('0.00'))
|
||||
category_totals = defaultdict(Decimal)
|
||||
for expense in expenses:
|
||||
category_name = expense.category.localized_name(get_locale()) if expense.category else _uncategorized()
|
||||
category_totals[category_name] += expense.amount
|
||||
budgets = Budget.query.filter_by(user_id=user_id, year=year, month=month).all()
|
||||
budget_map = {budget.category_id: budget for budget in budgets}
|
||||
alerts = []
|
||||
for expense in expenses:
|
||||
budget = budget_map.get(expense.category_id)
|
||||
if budget and expense.category:
|
||||
spent = category_totals.get(expense.category.localized_name(get_locale()), Decimal('0.00'))
|
||||
ratio = (spent / budget.amount * 100) if budget.amount else 0
|
||||
if ratio >= budget.alert_percent:
|
||||
alerts.append({'category': expense.category.localized_name(get_locale()), 'ratio': float(ratio), 'budget': float(budget.amount)})
|
||||
return expenses, total, category_totals, alerts
|
||||
|
||||
|
||||
def yearly_totals(user_id: int, year: int, month: int | None = None):
|
||||
if month:
|
||||
return daily_totals(user_id, year, month)
|
||||
rows = _base_user_query(user_id, year).with_entities(extract('month', Expense.purchase_date).label('month'), func.sum(Expense.amount)).group_by('month').order_by('month').all()
|
||||
return [{'month': int(month), 'amount': float(amount)} for month, amount in rows]
|
||||
|
||||
|
||||
def daily_totals(user_id: int, year: int, month: int | None = None):
|
||||
rows = _base_user_query(user_id, year, month).with_entities(extract('day', Expense.purchase_date).label('day'), func.sum(Expense.amount)).group_by('day').order_by('day').all()
|
||||
return [{'month': int(day), 'amount': float(amount)} for day, amount in rows]
|
||||
|
||||
|
||||
def yearly_category_totals(user_id: int, year: int, month: int | None = None):
|
||||
expenses = _base_user_query(user_id, year, month).all()
|
||||
grouped = defaultdict(Decimal)
|
||||
for expense in expenses:
|
||||
name = expense.category.localized_name(get_locale()) if expense.category else _uncategorized()
|
||||
grouped[name] += expense.amount
|
||||
return [{'category': name, 'amount': float(amount)} for name, amount in grouped.items()]
|
||||
|
||||
|
||||
def payment_method_totals(user_id: int, year: int, month: int | None = None):
|
||||
rows = _base_user_query(user_id, year, month).with_entities(Expense.payment_method, func.sum(Expense.amount)).group_by(Expense.payment_method).all()
|
||||
return [{'method': method, 'amount': float(amount)} for method, amount in rows]
|
||||
|
||||
|
||||
def top_expenses(user_id: int, year: int, month: int | None = None, limit: int = 10):
|
||||
rows = _base_user_query(user_id, year, month).order_by(Expense.amount.desc()).limit(limit).all()
|
||||
return [{'title': row.title, 'amount': float(row.amount), 'date': row.purchase_date.isoformat()} for row in rows]
|
||||
|
||||
|
||||
def yearly_overview(user_id: int, year: int, month: int | None = None):
|
||||
expenses = _base_user_query(user_id, year, month).all()
|
||||
total = sum((expense.amount for expense in expenses), Decimal('0.00'))
|
||||
count = len(expenses)
|
||||
average = (total / count) if count else Decimal('0.00')
|
||||
refunds = sum((expense.amount for expense in expenses if expense.is_refund), Decimal('0.00'))
|
||||
business_total = sum((expense.amount for expense in expenses if expense.is_business), Decimal('0.00'))
|
||||
return {'total': float(total), 'count': count, 'average': float(average), 'refunds': float(refunds), 'business_total': float(business_total)}
|
||||
|
||||
|
||||
def compare_years(user_id: int, year: int, month: int | None = None):
|
||||
current = yearly_overview(user_id, year, month)
|
||||
previous = yearly_overview(user_id, year - 1, month)
|
||||
diff = current['total'] - previous['total']
|
||||
pct = ((diff / previous['total']) * 100) if previous['total'] else 0
|
||||
return {'current_year': year, 'previous_year': year - 1, 'current_total': current['total'], 'previous_total': previous['total'], 'difference': diff, 'percent_change': pct}
|
||||
|
||||
|
||||
def range_totals(user_id: int, start_year: int, end_year: int, month: int | None = None):
|
||||
rows = Expense.query.with_entities(extract('year', Expense.purchase_date).label('year'), func.sum(Expense.amount)).filter_by(user_id=user_id, is_deleted=False).filter(extract('year', Expense.purchase_date) >= start_year, extract('year', Expense.purchase_date) <= end_year)
|
||||
if month:
|
||||
rows = rows.filter(extract('month', Expense.purchase_date) == month)
|
||||
rows = rows.group_by('year').order_by('year').all()
|
||||
return [{'year': int(year), 'amount': float(amount)} for year, amount in rows]
|
||||
|
||||
|
||||
def quarterly_totals(user_id: int, year: int, month: int | None = None):
|
||||
expenses = _base_user_query(user_id, year, month).all()
|
||||
quarters = {1: Decimal('0.00'), 2: Decimal('0.00'), 3: Decimal('0.00'), 4: Decimal('0.00')}
|
||||
for expense in expenses:
|
||||
quarter = ((expense.purchase_date.month - 1) // 3) + 1
|
||||
quarters[quarter] += expense.amount
|
||||
return [{'quarter': f'Q{quarter}', 'amount': float(amount)} for quarter, amount in quarters.items() if amount > 0]
|
||||
|
||||
|
||||
def weekday_totals(user_id: int, year: int, month: int | None = None):
|
||||
expenses = _base_user_query(user_id, year, month).all()
|
||||
labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] if get_locale() == 'en' else ['Pon', 'Wt', 'Śr', 'Czw', 'Pt', 'Sob', 'Niedz']
|
||||
totals = [Decimal('0.00') for _ in range(7)]
|
||||
for expense in expenses:
|
||||
totals[expense.purchase_date.weekday()] += expense.amount
|
||||
return [{'day': labels[index], 'amount': float(amount)} for index, amount in enumerate(totals)]
|
||||
18
app/services/assets.py
Normal file
18
app/services/assets.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import hashlib
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from flask import url_for
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def asset_version(file_path: str) -> str:
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
return '0'
|
||||
return hashlib.md5(path.read_bytes()).hexdigest()[:10]
|
||||
|
||||
|
||||
def asset_url(relative_path: str) -> str:
|
||||
base_path = Path(__file__).resolve().parent.parent / 'static' / relative_path
|
||||
return url_for('static', filename=relative_path, v=asset_version(str(base_path)))
|
||||
20
app/services/audit.py
Normal file
20
app/services/audit.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from flask_login import current_user
|
||||
|
||||
from ..extensions import db
|
||||
from ..models import AuditLog
|
||||
|
||||
|
||||
def log_action(action: str, target_type: str = '', target_id: str = '', **details) -> None:
|
||||
user_id = current_user.id if getattr(current_user, 'is_authenticated', False) else None
|
||||
entry = AuditLog(
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=str(target_id or ''),
|
||||
details=json.dumps(details, ensure_ascii=False) if details else '',
|
||||
)
|
||||
db.session.add(entry)
|
||||
25
app/services/categorization.py
Normal file
25
app/services/categorization.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..models import Expense
|
||||
|
||||
|
||||
def suggest_category_id(user_id: int, vendor: str = '', title: str = '') -> int | None:
|
||||
vendor = (vendor or '').strip().lower()
|
||||
title = (title or '').strip().lower()
|
||||
if not vendor and not title:
|
||||
return None
|
||||
recent = (
|
||||
Expense.query.filter_by(user_id=user_id, is_deleted=False)
|
||||
.filter(Expense.category_id.isnot(None))
|
||||
.order_by(Expense.id.desc())
|
||||
.limit(100)
|
||||
.all()
|
||||
)
|
||||
for expense in recent:
|
||||
ev = (expense.vendor or '').strip().lower()
|
||||
et = (expense.title or '').strip().lower()
|
||||
if vendor and ev == vendor:
|
||||
return expense.category_id
|
||||
if title and et == title:
|
||||
return expense.category_id
|
||||
return None
|
||||
47
app/services/export.py
Normal file
47
app/services/export.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from io import BytesIO, StringIO
|
||||
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
|
||||
def export_expenses_csv(expenses):
|
||||
output = StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['Date', 'Title', 'Vendor', 'Category', 'Amount', 'Currency', 'Payment method', 'Tags'])
|
||||
for expense in expenses:
|
||||
writer.writerow([
|
||||
expense.purchase_date.isoformat(),
|
||||
expense.title,
|
||||
expense.vendor,
|
||||
expense.category.name_en if expense.category else '',
|
||||
str(expense.amount),
|
||||
expense.currency,
|
||||
expense.payment_method,
|
||||
expense.tags,
|
||||
])
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def export_expenses_pdf(expenses, title: str = 'Expenses'):
|
||||
buffer = BytesIO()
|
||||
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||
width, height = A4
|
||||
y = height - 40
|
||||
pdf.setFont('Helvetica-Bold', 14)
|
||||
pdf.drawString(40, y, title)
|
||||
y -= 30
|
||||
pdf.setFont('Helvetica', 10)
|
||||
for expense in expenses:
|
||||
line = f'{expense.purchase_date.isoformat()} | {expense.title} | {expense.amount} {expense.currency}'
|
||||
pdf.drawString(40, y, line[:100])
|
||||
y -= 16
|
||||
if y < 60:
|
||||
pdf.showPage()
|
||||
y = height - 40
|
||||
pdf.setFont('Helvetica', 10)
|
||||
pdf.save()
|
||||
buffer.seek(0)
|
||||
return buffer.read()
|
||||
63
app/services/files.py
Normal file
63
app/services/files.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from pillow_heif import register_heif_opener
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
register_heif_opener()
|
||||
|
||||
|
||||
def allowed_file(filename: str, allowed_extensions: set[str]) -> bool:
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions
|
||||
|
||||
|
||||
def _apply_image_ops(image: Image.Image, rotate: int = 0, crop_box: tuple[int, int, int, int] | None = None, scale_percent: int = 100) -> Image.Image:
|
||||
if image.mode not in ('RGB', 'RGBA'):
|
||||
image = image.convert('RGB')
|
||||
if rotate:
|
||||
image = image.rotate(-rotate, expand=True)
|
||||
if crop_box:
|
||||
x, y, w, h = crop_box
|
||||
if w > 0 and h > 0:
|
||||
image = image.crop((x, y, x + w, y + h))
|
||||
if scale_percent and scale_percent != 100:
|
||||
width = max(1, int(image.width * (scale_percent / 100)))
|
||||
height = max(1, int(image.height * (scale_percent / 100)))
|
||||
image = image.resize((width, height))
|
||||
return image
|
||||
|
||||
|
||||
def _pdf_placeholder(preview_path: Path, original_name: str) -> None:
|
||||
image = Image.new('RGB', (800, 1100), color='white')
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.text((40, 40), f'PDF preview\n{original_name}', fill='black')
|
||||
image.save(preview_path, 'WEBP', quality=80)
|
||||
|
||||
|
||||
def save_document(file: FileStorage, upload_dir: Path, preview_dir: Path, rotate: int = 0, crop_box: tuple[int, int, int, int] | None = None, scale_percent: int = 100) -> tuple[str, str]:
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
original_name = secure_filename(file.filename or 'document')
|
||||
extension = original_name.rsplit('.', 1)[1].lower() if '.' in original_name else 'bin'
|
||||
stem = re.sub(r'[^a-zA-Z0-9_-]+', '-', Path(original_name).stem).strip('-') or 'document'
|
||||
unique_name = f'{stem}-{uuid4().hex}.{extension}'
|
||||
saved_path = upload_dir / unique_name
|
||||
file.save(saved_path)
|
||||
|
||||
preview_name = f'{stem}-{uuid4().hex}.webp'
|
||||
preview_path = preview_dir / preview_name
|
||||
if extension in {'jpg', 'jpeg', 'png', 'heic'}:
|
||||
image = Image.open(saved_path)
|
||||
image = _apply_image_ops(image, rotate=rotate, crop_box=crop_box, scale_percent=scale_percent)
|
||||
image.save(preview_path, 'WEBP', quality=80)
|
||||
elif extension == 'pdf':
|
||||
_pdf_placeholder(preview_path, original_name)
|
||||
else:
|
||||
preview_name = ''
|
||||
return unique_name, preview_name
|
||||
37
app/services/i18n.py
Normal file
37
app/services/i18n.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from flask import current_app, session
|
||||
from flask_login import current_user
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def load_language(language: str) -> dict:
|
||||
base = Path(__file__).resolve().parent.parent / 'static' / 'i18n'
|
||||
file_path = base / f'{language}.json'
|
||||
if not file_path.exists():
|
||||
file_path = base / 'pl.json'
|
||||
return json.loads(file_path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def get_locale() -> str:
|
||||
if getattr(current_user, 'is_authenticated', False):
|
||||
return current_user.language or current_app.config['DEFAULT_LANGUAGE']
|
||||
return session.get('language', current_app.config['DEFAULT_LANGUAGE'])
|
||||
|
||||
|
||||
def translate(key: str, default: str | None = None) -> str:
|
||||
language = get_locale()
|
||||
data = load_language(language)
|
||||
if key in data:
|
||||
return data[key]
|
||||
fallback = load_language(current_app.config.get('DEFAULT_LANGUAGE', 'pl'))
|
||||
if key in fallback:
|
||||
return fallback[key]
|
||||
en = load_language('en')
|
||||
return en.get(key, default or key)
|
||||
|
||||
|
||||
def inject_i18n():
|
||||
return {'t': translate, 'current_language': get_locale()}
|
||||
55
app/services/mail.py
Normal file
55
app/services/mail.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
|
||||
from flask import current_app, render_template
|
||||
|
||||
from .settings import get_bool_setting, get_int_setting, get_str_setting
|
||||
|
||||
|
||||
class MailService:
|
||||
def _settings(self) -> dict:
|
||||
security = get_str_setting('smtp_security', '') or ('ssl' if get_bool_setting('smtp_use_ssl', current_app.config.get('MAIL_USE_SSL', True)) else ('starttls' if get_bool_setting('smtp_use_tls', current_app.config.get('MAIL_USE_TLS', False)) else 'plain'))
|
||||
return {
|
||||
'host': get_str_setting('smtp_host', current_app.config.get('MAIL_SERVER', '')),
|
||||
'port': get_int_setting('smtp_port', current_app.config.get('MAIL_PORT', 465)),
|
||||
'username': get_str_setting('smtp_username', current_app.config.get('MAIL_USERNAME', '')),
|
||||
'password': get_str_setting('smtp_password', current_app.config.get('MAIL_PASSWORD', '')),
|
||||
'sender': get_str_setting('smtp_sender', current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@example.com')),
|
||||
'security': security,
|
||||
}
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self._settings()['host'])
|
||||
|
||||
def send(self, to_email: str, subject: str, body: str, html: str | None = None) -> bool:
|
||||
cfg = self._settings()
|
||||
if not cfg['host']:
|
||||
current_app.logger.info('Mail skipped for %s: %s', to_email, subject)
|
||||
return False
|
||||
msg = EmailMessage()
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = cfg['sender']
|
||||
msg['To'] = to_email
|
||||
msg.set_content(body)
|
||||
if html:
|
||||
msg.add_alternative(html, subtype='html')
|
||||
if cfg['security'] == 'ssl':
|
||||
with smtplib.SMTP_SSL(cfg['host'], cfg['port']) as smtp:
|
||||
if cfg['username']:
|
||||
smtp.login(cfg['username'], cfg['password'])
|
||||
smtp.send_message(msg)
|
||||
else:
|
||||
with smtplib.SMTP(cfg['host'], cfg['port']) as smtp:
|
||||
if cfg['security'] == 'starttls':
|
||||
smtp.starttls()
|
||||
if cfg['username']:
|
||||
smtp.login(cfg['username'], cfg['password'])
|
||||
smtp.send_message(msg)
|
||||
return True
|
||||
|
||||
def send_template(self, to_email: str, subject: str, template_name: str, **context) -> bool:
|
||||
html = render_template(f'mail/{template_name}.html', **context)
|
||||
text = render_template(f'mail/{template_name}.txt', **context)
|
||||
return self.send(to_email, subject, text, html=html)
|
||||
52
app/services/ocr.py
Normal file
52
app/services/ocr.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytesseract
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
AMOUNT_REGEXES = [
|
||||
re.compile(r'(?:suma|total|razem)\s*[:]?\s*(\d+[\.,]\d{2})', re.I),
|
||||
re.compile(r'(\d+[\.,]\d{2})'),
|
||||
]
|
||||
DATE_REGEX = re.compile(r'(\d{4}-\d{2}-\d{2}|\d{2}[./-]\d{2}[./-]\d{4})')
|
||||
NIP_REGEX = re.compile(r'(?:NIP|TIN)\s*[:]?\s*([0-9\- ]{8,20})', re.I)
|
||||
|
||||
|
||||
class OCRResult(dict):
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.get('status', 'pending')
|
||||
|
||||
|
||||
class OCRService:
|
||||
def extract(self, file_path: Path) -> OCRResult:
|
||||
if file_path.suffix.lower() not in {'.jpg', '.jpeg', '.png', '.heic', '.webp'}:
|
||||
return OCRResult(status='pending', title=file_path.stem, vendor='', amount=None, purchase_date=None)
|
||||
try:
|
||||
image = Image.open(file_path)
|
||||
image = ImageOps.exif_transpose(image)
|
||||
text = pytesseract.image_to_string(image, lang='eng')
|
||||
except Exception:
|
||||
return OCRResult(status='pending', title=file_path.stem, vendor='', amount=None, purchase_date=None)
|
||||
|
||||
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||||
vendor = lines[0][:255] if lines else ''
|
||||
amount = None
|
||||
for pattern in AMOUNT_REGEXES:
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
amount = match.group(1).replace(',', '.')
|
||||
break
|
||||
date_match = DATE_REGEX.search(text)
|
||||
nip_match = NIP_REGEX.search(text)
|
||||
return OCRResult(
|
||||
status='review' if amount or vendor else 'pending',
|
||||
title=vendor or file_path.stem,
|
||||
vendor=vendor,
|
||||
amount=amount,
|
||||
purchase_date=date_match.group(1) if date_match else None,
|
||||
tax_id=nip_match.group(1).strip() if nip_match else None,
|
||||
raw_text=text[:4000],
|
||||
)
|
||||
42
app/services/reporting.py
Normal file
42
app/services/reporting.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from ..extensions import db
|
||||
from ..models import Expense, ReportLog, User
|
||||
from .mail import MailService
|
||||
from .settings import get_bool_setting
|
||||
|
||||
|
||||
def _range_for_frequency(frequency: str, today: date):
|
||||
if frequency == 'daily':
|
||||
start = today - timedelta(days=1)
|
||||
label = start.isoformat()
|
||||
elif frequency == 'weekly':
|
||||
start = today - timedelta(days=7)
|
||||
label = f'{start.isoformat()}..{today.isoformat()}'
|
||||
else:
|
||||
start = date(today.year, today.month, 1)
|
||||
label = f'{today.year}-{today.month:02d}'
|
||||
return start, today, label
|
||||
|
||||
|
||||
def send_due_reports(today: date | None = None) -> int:
|
||||
if not get_bool_setting('reports_enabled', True):
|
||||
return 0
|
||||
today = today or date.today()
|
||||
sent = 0
|
||||
users = User.query.filter(User.report_frequency.in_(['daily', 'weekly', 'monthly'])).all()
|
||||
for user in users:
|
||||
start, end, label = _range_for_frequency(user.report_frequency, today)
|
||||
if ReportLog.query.filter_by(user_id=user.id, frequency=user.report_frequency, period_label=label).first():
|
||||
continue
|
||||
q = Expense.query.filter_by(user_id=user.id, is_deleted=False).filter(Expense.purchase_date >= start, Expense.purchase_date <= end)
|
||||
rows = q.order_by(Expense.purchase_date.desc()).all()
|
||||
total = sum((expense.amount for expense in rows), Decimal('0.00'))
|
||||
MailService().send_template(user.email, f'Expense report {label}', 'expense_report', user=user, period_label=label, total=total, currency=user.default_currency, expenses=rows[:10])
|
||||
db.session.add(ReportLog(user_id=user.id, frequency=user.report_frequency, period_label=label))
|
||||
sent += 1
|
||||
db.session.commit()
|
||||
return sent
|
||||
32
app/services/scheduler.py
Normal file
32
app/services/scheduler.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from .reporting import send_due_reports
|
||||
from .settings import get_bool_setting
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
|
||||
|
||||
def get_scheduler() -> BackgroundScheduler:
|
||||
global _scheduler
|
||||
if _scheduler is None:
|
||||
_scheduler = BackgroundScheduler(timezone='UTC')
|
||||
return _scheduler
|
||||
|
||||
|
||||
def start_scheduler(app) -> None:
|
||||
if app.config.get('TESTING'):
|
||||
return
|
||||
if not get_bool_setting('report_scheduler_enabled', False):
|
||||
return
|
||||
scheduler = get_scheduler()
|
||||
if scheduler.running:
|
||||
return
|
||||
|
||||
def _job():
|
||||
with app.app_context():
|
||||
send_due_reports()
|
||||
|
||||
scheduler.add_job(_job, 'interval', minutes=60, id='send_due_reports', replace_existing=True)
|
||||
scheduler.start()
|
||||
21
app/services/settings.py
Normal file
21
app/services/settings.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..models import AppSetting
|
||||
|
||||
|
||||
def get_bool_setting(key: str, default: bool = False) -> bool:
|
||||
raw = AppSetting.get(key, 'true' if default else 'false')
|
||||
return str(raw).lower() == 'true'
|
||||
|
||||
|
||||
def get_int_setting(key: str, default: int) -> int:
|
||||
raw = AppSetting.get(key)
|
||||
try:
|
||||
return int(raw) if raw is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def get_str_setting(key: str, default: str = '') -> str:
|
||||
raw = AppSetting.get(key)
|
||||
return str(raw) if raw is not None else default
|
||||
Reference in New Issue
Block a user