This commit is contained in:
2026-04-07 12:44:06 +05:30
parent 7f81fc64c1
commit 5dd4196014
49 changed files with 2795 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy import select
import bcrypt
from app_core.db.database import engine, Base, SessionLocal
from app_core.db.models import User
# Create tables on import
Base.metadata.create_all(bind=engine)
class AuthService:
def __init__(self) -> None:
self._session_factory = SessionLocal
def _hash_password(self, raw_password: str) -> str:
salt = bcrypt.gensalt()
return bcrypt.hashpw(raw_password.encode("utf-8"), salt).decode("utf-8")
def _verify_password(self, raw_password: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(raw_password.encode("utf-8"), hashed.encode("utf-8"))
except Exception:
return False
def signup(self, email: str, password: str) -> tuple[bool, str]:
email = email.strip().lower()
if not email or not password:
return False, "Email and password are required."
with self._session_factory() as db: # type: Session
exists = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if exists:
return False, "Email already registered."
user = User(email=email, password_hash=self._hash_password(password))
db.add(user)
db.commit()
return True, "Account created. Please login."
def login(self, email: str, password: str) -> tuple[bool, Optional[dict], str]:
email = email.strip().lower()
if not email or not password:
return False, None, "Email and password are required."
with self._session_factory() as db: # type: Session
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if not user or not self._verify_password(password, user.password_hash):
return False, None, "Invalid credentials."
return True, {"id": user.id, "email": user.email}, "Login successful."

View File

@@ -0,0 +1,53 @@
import os
from datetime import datetime
from zoneinfo import ZoneInfo
from app_core.config.settings import AppSettings
from app_core.services.mailer_service import MailerService
def main(for_date: str | None = None, force: bool = False) -> int:
settings = AppSettings()
service = MailerService(settings)
if for_date:
try:
chosen = datetime.strptime(for_date, "%Y-%m-%d").date()
except ValueError:
print(f"Invalid date format: {for_date}. Expected YYYY-MM-DD.")
return 1
else:
today_ist = datetime.now(ZoneInfo("Asia/Kolkata")).date()
chosen = service.select_report_date(preferred=today_ist)
if not chosen:
print("No data available to send.")
return 1
if not force and service.has_sent_for_date(str(chosen)):
print(f"Already sent for {chosen}; skipping.")
return 0
df = service.fetch_daily_rows(chosen)
if df.empty:
print("Selected date has no rows; nothing to send.")
return 0
row = df.iloc[0].to_dict()
html = service.build_email_html(row, df)
recipients_env = settings.report_recipients or os.getenv("REPORT_RECIPIENTS")
if not recipients_env:
print("REPORT_RECIPIENTS env var is empty. Set it to comma-separated emails.")
return 2
recipients = [r.strip() for r in recipients_env.split(',') if r.strip()]
ok, msg = service.send_email(recipients, subject=f"Daily Digest - {chosen}", html=html)
service.log_email(recipients, subject=f"Daily Digest - {chosen}", date_for=str(chosen), status="sent" if ok else "failed", error=None if ok else msg)
print("Sent" if ok else f"Failed: {msg}")
return 0 if ok else 3
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,327 @@
from __future__ import annotations
import smtplib
import sys
import os
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import List, Tuple
from datetime import date
from datetime import date
import pandas as pd
from sqlalchemy import text
import streamlit as st
# Add the project root to Python path
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if project_root not in sys.path:
sys.path.insert(0, project_root)
from app_core.config.settings import AppSettings, STORES
from app_core.db.database import engine, SessionLocal
from app_core.db.models import EmailLog
class MailerService:
def __init__(self, settings: AppSettings | None = None) -> None:
self.settings = settings or AppSettings()
def fetch_daily_rows(self, report_date) -> pd.DataFrame:
sql = (
'SELECT * FROM "tenantpostings" '
'WHERE "created_at"::date = %(d)s '
'ORDER BY "id" DESC '
'LIMIT 10000'
)
with engine.connect() as conn:
df = pd.read_sql(sql, conn, params={"d": report_date})
return df
def select_report_date(self, preferred: date | None = None) -> date | None:
"""Return preferred date if it has data; else most recent date with data; else None."""
with engine.connect() as conn:
dates_df = pd.read_sql(
'SELECT "created_at"::date AS d, COUNT(*) AS c\n'
'FROM "tenantpostings"\n'
'GROUP BY d\n'
'ORDER BY d DESC',
conn,
)
if dates_df.empty:
return None
# Normalize
if 'd' not in dates_df.columns:
return None
dates_df['d'] = pd.to_datetime(dates_df['d'], errors='coerce')
available = [d.date() for d in dates_df['d'].dropna().tolist()]
if preferred and preferred in available:
return preferred
return available[0] if available else None
def build_email_html(self, row: dict, df: pd.DataFrame | None = None) -> str:
outlet = row.get("outlet_name") or row.get("register_name") or "Outlet"
division = row.get("division_code") or "PC"
status = (row.get("triumph_status") or "Posted successfully").capitalize()
register_close_id = row.get("register_close_id", "")
register_id = row.get("register_id", "")
def lines_for(ptype: str) -> list[str]:
"""Return formatted lines for all rows of a processing_type.
Example line: 3,616.19 (Event ID: 2904783)
"""
if df is None or df.empty or 'processing_type' not in df.columns:
return []
sub = df[df['processing_type'].astype(str).str.upper() == ptype.upper()] if 'processing_type' in df.columns else pd.DataFrame()
if sub.empty:
return []
# De-duplicate by triumph_event to avoid double-counting retries
if 'triumph_event' in sub.columns:
sub = sub.sort_values(['triumph_event', 'id'], ascending=[True, False]).drop_duplicates(subset=['triumph_event'], keep='first')
result: list[str] = []
for _, r in sub.sort_values('id', ascending=False).iterrows():
amt = r.get('total_amount')
evt = r.get('triumph_event', '')
try:
amt_str = f"{float(amt):,.2f}"
except Exception:
amt_str = str(amt) if amt is not None else ''
result.append(f"<span style=\"font-weight:600;\">{amt_str}</span> (Event ID: <span style=\"font-weight:600;\">{evt}</span>)")
return result
journal_lines = lines_for('JOURNAL')
bank_journal_lines = lines_for('BANKING_JOURNAL')
invoice_lines = lines_for('INVOICE')
receipt_lines = lines_for('RECEIPT')
# Optional: transaction summary by store (single table)
store_summary_table_html = ""
events_matrix_html = ""
if isinstance(df, pd.DataFrame) and not df.empty and ('tenant_id' in df.columns):
def summarize_for(store: dict) -> dict[str, str]:
sid = store.get('tenant_id')
name = store.get('label')
sub = df[df['tenant_id'] == sid]
# De-duplicate each processing type context within the store
if not sub.empty and 'triumph_event' in sub.columns and 'processing_type' in sub.columns:
# Filter out non-event rows before dedupe if necessary, but here we just dedupe everything with an event ID
# We keep the one with highest ID for each event
has_event = sub['triumph_event'].fillna('').astype(str).str.strip() != ''
sub_with_events = sub[has_event].sort_values(['processing_type', 'triumph_event', 'id'], ascending=[True, True, False]).drop_duplicates(subset=['processing_type', 'triumph_event'], keep='first')
sub_no_events = sub[~has_event]
sub = pd.concat([sub_with_events, sub_no_events]).sort_values('id', ascending=False)
def pick_total(kind: str) -> tuple[str, int]:
if sub.empty or 'processing_type' not in sub.columns:
return ("0.00", 0)
s = sub[sub['processing_type'].astype(str).str.upper() == kind]
if s.empty:
return ("0.00", 0)
try:
total = float(s['total_amount'].fillna(0).sum()) if 'total_amount' in s.columns else 0.0
except Exception:
total = 0.0
return (f"{total:,.2f}", len(s))
def has_rows(kind: str) -> bool:
if sub.empty or 'processing_type' not in sub.columns:
return False
s = sub[sub['processing_type'].astype(str).str.upper() == kind]
return not s.empty
def latest_event(kind: str) -> str:
if sub.empty or 'processing_type' not in sub.columns:
return ""
s = sub[sub['processing_type'].astype(str).str.upper() == kind]
if s.empty:
return ""
series = s.get('triumph_event') if 'triumph_event' in s.columns else None
if series is None or series.empty:
return ""
try:
return str(series.dropna().astype(str).iloc[0])
except Exception:
return ""
def latest_status_emoji(kind: str) -> str:
if sub.empty or 'processing_type' not in sub.columns:
return ""
s = sub[sub['processing_type'].astype(str).str.upper() == kind]
if s.empty:
return ""
status_series = s.get('triumph_status') if 'triumph_status' in s.columns else None
if status_series is None or status_series.empty:
return ""
try:
val = str(status_series.iloc[0]).strip().lower()
except Exception:
val = ""
if any(x in val for x in ["success", "ok", "completed", "done"]):
return ""
if any(x in val for x in ["fail", "error", "invalid", "dead"]):
return ""
if any(x in val for x in ["pending", "queue", "waiting", "processing"]):
return " ⚠️"
return ""
j_total, _ = pick_total('JOURNAL')
b_total, _ = pick_total('BANKING_JOURNAL')
i_total, _ = pick_total('INVOICE')
r_total, _ = pick_total('RECEIPT')
j_eid = latest_event('JOURNAL'); j_stat = latest_status_emoji('JOURNAL')
b_eid = latest_event('BANKING_JOURNAL'); b_stat = latest_status_emoji('BANKING_JOURNAL')
i_eid = latest_event('INVOICE'); i_stat = latest_status_emoji('INVOICE')
r_eid = latest_event('RECEIPT'); r_stat = latest_status_emoji('RECEIPT')
def render_cell(exists: bool, total: str, eid: str, stat: str, ptype: str = "") -> str:
if not exists:
return "<span style=\"color:#9AA4B2;\">Nill</span>"
# For INVOICE and RECEIPT, show individual line items if multiple exist
if ptype.upper() in ['INVOICE', 'RECEIPT'] and sub is not None and not sub.empty:
type_sub = sub[sub['processing_type'].astype(str).str.upper() == ptype.upper()]
if len(type_sub) > 1: # Multiple transactions
individual_lines = []
for _, r in type_sub.sort_values('id', ascending=False).iterrows():
amt = r.get('total_amount')
evt = r.get('triumph_event', '')
status_val = str(r.get('triumph_status', '')).strip().lower()
status_emoji = ""
if any(x in status_val for x in ["success", "ok", "completed", "done"]):
status_emoji = ""
elif any(x in status_val for x in ["fail", "error", "invalid", "dead"]):
status_emoji = ""
elif any(x in status_val for x in ["pending", "queue", "waiting", "processing"]):
status_emoji = " ⚠️"
try:
amt_str = f"{float(amt):,.2f}"
except Exception:
amt_str = str(amt) if amt is not None else ''
individual_lines.append(f"<div style=\"font-size:11px;margin:1px 0;\">{amt_str} ({evt}){status_emoji}</div>")
return f"<strong>{total}</strong><br/><span style=\"color:#64748b;font-size:10px;\">Total ({len(type_sub)} items)</span><br/>{''.join(individual_lines)}"
return f"<strong>{total}</strong><br/><span style=\"color:#64748b\">({eid})</span> {stat}"
return {
"name": name,
"journal": render_cell(has_rows('JOURNAL'), j_total, j_eid, j_stat),
"banking": render_cell(has_rows('BANKING_JOURNAL'), b_total, b_eid, b_stat),
"invoice": render_cell(has_rows('INVOICE'), i_total, i_eid, i_stat, 'INVOICE'),
"receipt": render_cell(has_rows('RECEIPT'), r_total, r_eid, r_stat, 'RECEIPT'),
}
rows = [summarize_for(s) for s in STORES]
# Build single HTML table
header = (
"<tr>"
"<th style=\"text-align:left;padding:10px;color:#E2E8F0;\">Store Name</th>"
"<th style=\"text-align:left;padding:10px;color:#E2E8F0;\">Journal</th>"
"<th style=\"text-align:left;padding:10px;color:#E2E8F0;\">Banking Journal</th>"
"<th style=\"text-align:left;padding:10px;color:#E2E8F0;\">Account Sales</th>"
"<th style=\"text-align:left;padding:10px;color:#E2E8F0;\">Account Payments</th>"
"</tr>"
)
body = []
for r in rows:
body.append(
"<tr>"
f"<td style=\"padding:10px;border-top:1px solid #1F2937;color:#F8FAFC;\">{r['name']}</td>"
f"<td style=\"padding:10px;border-top:1px solid #1F2937;color:#F8FAFC;\">{r['journal']}</td>"
f"<td style=\"padding:10px;border-top:1px solid #1F2937;color:#F8FAFC;\">{r['banking']}</td>"
f"<td style=\"padding:10px;border-top:1px solid #1F2937;color:#F8FAFC;\">{r['invoice']}</td>"
f"<td style=\"padding:10px;border-top:1px solid #1F2937;color:#F8FAFC;\">{r['receipt']}</td>"
"</tr>"
)
store_summary_table_html = (
"<div style=\"background:#111827;border-radius:12px;padding:12px;\">"
"<div style=\"font-weight:700;color:#F8FAFC;margin-bottom:6px;\">Transaction Summary by Store</div>"
"<table style=\"width:100%;border-collapse:collapse;font-size:12px;\">"
+ header + "".join(body) + "</table></div>"
)
html = f"""
<div style="font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; color:#0f172a; font-size:13px; line-height:1.5;">
<p style="margin:0 0 8px 0">Hello <strong>Tucker Fresh</strong>,</p>
<p style="margin:0 0 12px 0">Heres your daily digest of posted transactions:</p>
{store_summary_table_html}
<p style="margin:12px 0 6px 0">Thank you for staying updated with us.</p>
<p style="margin:0">Best regards,<br/><strong>Workolik Team</strong></p>
</div>
"""
return html
def send_email(self, recipients: List[str], subject: str, html: str) -> Tuple[bool, str]:
s = self.settings
if not all([s.smtp_host, s.smtp_port, s.smtp_user, s.smtp_password, s.smtp_from_email]):
return False, "SMTP settings are incomplete."
# Optional BCC via env (comma-separated), default empty
bcc_env = os.getenv("BCC_RECIPIENTS", "").strip()
bcc_recipients = [e.strip() for e in bcc_env.split(',') if e.strip()] if bcc_env else []
all_recipients = recipients + bcc_recipients
msg = MIMEMultipart("alternative")
msg["From"] = f"{s.smtp_from_name} <{s.smtp_from_email}>"
msg["To"] = ", ".join(recipients)
msg["Subject"] = subject
msg.attach(MIMEText(html, "html"))
try:
server = smtplib.SMTP(s.smtp_host, s.smtp_port, timeout=30)
if s.smtp_use_tls:
server.starttls()
server.login(s.smtp_user, s.smtp_password)
server.sendmail(s.smtp_from_email, all_recipients, msg.as_string())
server.quit()
return True, "sent"
except Exception as e:
return False, str(e)
def log_email(self, recipients: List[str], subject: str, date_for: str, status: str, error: str | None = None) -> None:
with SessionLocal() as db:
entry = EmailLog(
recipients=", ".join(recipients),
subject=subject,
status=status,
error=error,
date_for=date_for,
)
db.add(entry)
db.commit()
def has_sent_for_date(self, date_for: str) -> bool:
"""Return True if a successful send log exists for the given date."""
with SessionLocal() as db:
row = (
db.query(EmailLog)
.filter(EmailLog.date_for == date_for, EmailLog.status == "sent")
.order_by(EmailLog.sent_at.desc())
.first()
)
return row is not None
def recent_logs(self, limit: int = 50) -> list[dict]:
return _get_recent_logs_cached(limit)
@st.cache_data(ttl=60) # Cache for 1 minute
def _get_recent_logs_cached(limit: int = 50) -> list[dict]:
"""Cached function to get recent email logs."""
with SessionLocal() as db:
rows = (
db.query(EmailLog)
.order_by(EmailLog.sent_at.desc())
.limit(limit)
.all()
)
return [
{
"id": r.id,
"sent_at": r.sent_at,
"recipients": r.recipients,
"subject": r.subject,
"status": r.status,
"error": r.error,
"date_for": r.date_for,
}
for r in rows
]

View File

@@ -0,0 +1,52 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app_core.db.database import SessionLocal
from app_core.db.models import TriumphDebtorMapping
from datetime import datetime
class MappingsService:
def __init__(self):
pass
def get_all_mappings(self) -> List[TriumphDebtorMapping]:
with SessionLocal() as db:
return db.query(TriumphDebtorMapping).order_by(TriumphDebtorMapping.id.asc()).all()
def get_mapping_by_id(self, mapping_id: int) -> Optional[TriumphDebtorMapping]:
with SessionLocal() as db:
return db.query(TriumphDebtorMapping).filter(TriumphDebtorMapping.id == mapping_id).first()
def create_mapping(self, code: str, name: str, dbmacc: str, outlet: str) -> TriumphDebtorMapping:
with SessionLocal() as db:
mapping = TriumphDebtorMapping(
code=code,
name=name,
dbmacc=dbmacc,
outlet=outlet
)
db.add(mapping)
db.commit()
db.refresh(mapping)
return mapping
def update_mapping(self, mapping_id: int, code: str, name: str, dbmacc: str, outlet: str) -> bool:
with SessionLocal() as db:
mapping = db.query(TriumphDebtorMapping).filter(TriumphDebtorMapping.id == mapping_id).first()
if mapping:
mapping.code = code
mapping.name = name
mapping.dbmacc = dbmacc
mapping.outlet = outlet
mapping.updated_at = datetime.now()
db.commit()
return True
return False
def delete_mapping(self, mapping_id: int) -> bool:
with SessionLocal() as db:
mapping = db.query(TriumphDebtorMapping).filter(TriumphDebtorMapping.id == mapping_id).first()
if mapping:
db.delete(mapping)
db.commit()
return True
return False

View File

@@ -0,0 +1,89 @@
import logging
import os
from datetime import datetime
from zoneinfo import ZoneInfo
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.executors.pool import ThreadPoolExecutor
from apscheduler.jobstores.memory import MemoryJobStore
from app_core.services.daily_report import main as run_daily_report
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class SchedulerService:
def __init__(self):
self.scheduler = None
self.ist = ZoneInfo("Asia/Kolkata")
def start_scheduler(self):
"""Start the background scheduler for daily email reports."""
if self.scheduler and self.scheduler.running:
logger.info("Scheduler is already running")
return
# Configure job stores and executors
jobstores = {
'default': MemoryJobStore()
}
executors = {
'default': ThreadPoolExecutor(20)
}
job_defaults = {
'coalesce': False,
'max_instances': 1
}
self.scheduler = BackgroundScheduler(
jobstores=jobstores,
executors=executors,
job_defaults=job_defaults,
timezone=self.ist
)
# Schedule daily email at 8:00 PM IST (20:00)
self.scheduler.add_job(
func=self._send_daily_report,
trigger=CronTrigger(hour=20, minute=0, timezone=self.ist),
id='daily_email_report',
name='Daily Email Report',
replace_existing=True
)
# Start the scheduler
self.scheduler.start()
logger.info("Daily email scheduler started - will send reports at 8:00 PM IST")
def stop_scheduler(self):
"""Stop the background scheduler."""
if self.scheduler and self.scheduler.running:
self.scheduler.shutdown()
logger.info("Daily email scheduler stopped")
def _send_daily_report(self):
"""Internal method to send daily report."""
try:
logger.info(f"Starting daily report at {datetime.now(self.ist)}")
result = run_daily_report()
if result == 0:
logger.info("Daily report sent successfully")
else:
logger.warning(f"Daily report failed with exit code: {result}")
except Exception as e:
logger.error(f"Error sending daily report: {str(e)}")
def get_next_run_time(self):
"""Get the next scheduled run time for the daily report."""
if not self.scheduler or not self.scheduler.running:
return None
job = self.scheduler.get_job('daily_email_report')
if job:
return job.next_run_time
return None
def is_running(self):
"""Check if scheduler is running."""
return self.scheduler is not None and self.scheduler.running