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

1
app_core/__init__.py Normal file
View File

@@ -0,0 +1 @@
# App Core Package

Binary file not shown.

View File

@@ -0,0 +1 @@
# Config Package

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,78 @@
import os
from dotenv import load_dotenv
from pydantic import BaseModel
# Load .env first (if present)
load_dotenv(dotenv_path=".env", override=False)
# Also load .env-example.txt as a fallback for local dev (does not override)
load_dotenv(dotenv_path=".env-example.txt", override=False)
class AppSettings(BaseModel):
# Raw pieces
db_host: str | None = os.getenv("DB_HOST")
db_port: str | None = os.getenv("DB_PORT")
db_name: str | None = os.getenv("DB_NAME")
db_user: str | None = os.getenv("DB_USER")
db_password: str | None = os.getenv("DB_PASSWORD")
db_echo: bool = os.getenv("DB_ECHO", "false").lower() == "true"
# Optional complete URL (takes precedence if set)
database_url_env: str | None = os.getenv("DATABASE_URL")
app_secret: str = os.getenv("APP_SECRET", "change_me")
background_image_url: str | None = os.getenv("BACKGROUND_IMAGE_URL")
# SMTP / Email settings
smtp_host: str | None = os.getenv("SMTP_HOST")
smtp_port: int | None = int(os.getenv("SMTP_PORT", "587"))
smtp_user: str | None = os.getenv("SMTP_USER")
smtp_password: str | None = os.getenv("SMTP_PASSWORD")
smtp_use_tls: bool = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
smtp_from_email: str | None = os.getenv("SMTP_FROM_EMAIL")
smtp_from_name: str = os.getenv("SMTP_FROM_NAME", "Workolik Team")
# Default recipients for automated reports (comma-separated)
report_recipients: str | None = os.getenv("REPORT_RECIPIENTS")
@property
def database_url(self) -> str:
if self.database_url_env:
# Normalize asyncpg to psycopg2 if needed
if self.database_url_env.startswith("postgresql+asyncpg://"):
return self.database_url_env.replace(
"postgresql+asyncpg://", "postgresql+psycopg2://", 1
)
return self.database_url_env
# Build from parts
if all([self.db_host, self.db_port, self.db_name, self.db_user, self.db_password]):
return (
f"postgresql+psycopg2://{self.db_user}:{self.db_password}"
f"@{self.db_host}:{self.db_port}/{self.db_name}"
)
# Fallback empty (will error at runtime if used)
return ""
# Fixed mapping of stores to tenant IDs and division codes
# Used by analytics and data pages to scope queries per store
STORES = [
{"label": "Porters Liquor Claremont - PC", "code": "PC", "tenant_id": 1},
{"label": "Porters Iluka - IP", "code": "IP", "tenant_id": 2},
{"label": "Cellarbrations at Morris Place - ML", "code": "ML", "tenant_id": 3},
{"label": "Cellarbrations at Lynwood - CL", "code": "CL4", "tenant_id": 4},
{"label": "Cellarbrations at Nicholson Road - NL", "code": "NL", "tenant_id": 5},
{"label": "Cellarbrations at Treeby - CL ", "code": "CL6", "tenant_id": 6},
{"label": "The Bottle-O Rossmoyne - RC", "code": "RC", "tenant_id": 7},
{"label": "Porters Liquor Piara Waters - PL", "code": "PL", "tenant_id": 8},
]
# Helper map for quick lookups by code (supports variants like CL-4 → CL4)
STORE_CODE_TO_TENANT_ID: dict[str, int] = {
"PC": 1,
"IP": 2,
"ML": 3,
"CL4": 4, "CL-4": 4, "CL_4": 4, "CL": 4, # default CL → 4
"NL": 5, "NL5": 5, "NL-5": 5,
"CL6": 6, "CL-6": 6, "CL_6": 6,
"RC": 7,
"PL": 8,
}

1
app_core/db/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Database Package

Binary file not shown.

Binary file not shown.

Binary file not shown.

21
app_core/db/database.py Normal file
View File

@@ -0,0 +1,21 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from app_core.config.settings import AppSettings
settings = AppSettings()
if not settings.database_url:
raise RuntimeError(
"Database configuration missing. Set DATABASE_URL or DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD in a .env file at the project root."
)
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True, echo=settings.db_echo)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, future=True)
Base = declarative_base()
def get_db_session():
db = SessionLocal()
try:
yield db
finally:
db.close()

36
app_core/db/models.py Normal file
View File

@@ -0,0 +1,36 @@
from sqlalchemy import Column, Integer, String, DateTime, func, UniqueConstraint
from .database import Base
class User(Base):
__tablename__ = "workolik_users"
__table_args__ = (
UniqueConstraint("email", name="uq_workolik_users_email"),
)
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), nullable=False, unique=True, index=True)
password_hash = Column(String(255), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
class EmailLog(Base):
__tablename__ = "email_logs"
id = Column(Integer, primary_key=True, index=True)
sent_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
recipients = Column(String(1024), nullable=False)
subject = Column(String(255), nullable=False)
status = Column(String(50), nullable=False) # sent / failed
error = Column(String(1024))
date_for = Column(String(32), nullable=False)
class TriumphDebtorMapping(Base):
__tablename__ = "triumph_debtor_mappings"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(50))
name = Column(String(255))
dbmacc = Column(String(50))
outlet = Column(String(255))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

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

Binary file not shown.

Binary file not shown.

37
app_core/ui/auth_ui.py Normal file
View File

@@ -0,0 +1,37 @@
import re
import streamlit as st
from app_core.services.auth_service import AuthService
def _is_valid_email(value: str) -> bool:
if not value:
return False
pattern = r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"
return re.match(pattern, value.strip()) is not None
def render_auth_card(auth_service: AuthService) -> None:
left, center, right = st.columns([1, 1.2, 1])
with center:
st.markdown('<div class="auth-title">Welcome !!</div>', unsafe_allow_html=True)
st.markdown('<div class="muted" style="margin-bottom:16px;">Sign in to continue</div>', unsafe_allow_html=True)
with st.form("login_form", clear_on_submit=False):
email = st.text_input("Email", placeholder="you@example.com", key="login_email")
password = st.text_input("Password", type="password", placeholder="••••••••", key="login_password")
submitted = st.form_submit_button("Sign in →", use_container_width=True)
if submitted:
if not _is_valid_email(email):
st.error("Enter a valid email address.")
elif not password:
st.error("Password is required.")
else:
ok, user, msg = auth_service.login(email, password)
if ok and user:
st.session_state.auth_user = user
st.success(msg)
st.rerun()
else:
st.error(msg)
st.markdown('</div>', unsafe_allow_html=True)

314
app_core/ui/layout.py Normal file
View File

@@ -0,0 +1,314 @@
import streamlit as st
from app_core.config.settings import STORES, STORE_CODE_TO_TENANT_ID
def apply_global_style(background_url: str | None = None) -> None:
css = """
<style>
:root { --brand: ; --brandDark: #0F4FD6; --text: #0F172A; --muted: #64748b; --border: rgba(15, 23, 42, 0.08); --inputBorder: rgba(22,98,243,0.35); }
.stApp { background: transparent !important; position: relative !important; min-height: 100vh; font-size: 17px; }
[data-testid="stAppViewContainer"] { background: transparent !important; }
[data-testid="stAppViewContainer"] > header, [data-testid="stHeader"], .stApp header { background: transparent !important; box-shadow: none !important; }
.stAppToolbar { background: transparent !important; box-shadow: none !important; border-bottom: none !important; }
/* Header sections */
.tfw-header { background: #FFFFFF; border-bottom: 1px solid var(--border); }
.tfw-header-white { background: #FFFFFF; padding: 16px 0; }
.tfw-header-grey { background: #F8FAFC; padding: 12px 0; border-top: 1px solid rgba(15, 23, 42, 0.00); }
.tfw-header-content { max-width: 1200px; margin: 0 auto; padding: 0 20px; }
/* Topbar (same height, narrower sides) */
.tfw-topbar { position: fixed; top: 0; left: 0; right: 0; height: 48px; background: rgba(248,250,252,0.6); backdrop-filter: saturate(180%) border-bottom: none; z-index: 999; }
.tfw-topbar .tfw-inner { height: 48px; display: flex; align-items: center; gap: 8px; padding: 0 8px; max-width: 1100px; margin: 0 auto; }
.tfw-logo { width: 18px; height: 18px; border-radius: 6px; background: linear-gradient(135deg, var(--brand), var(--brandDark)); display: inline-block; }
.tfw-title { font-weight: 800; color: var(--text); letter-spacing: -0.02em; font-size: 1.0rem; }
/* Sidebar enhancements */
[data-testid="stSidebar"] .sidebar-content { display: flex; flex-direction: column; height: 100%; font-size: 1rem; }
[data-testid="stSidebar"] .sidebar-section { padding: 8px 6px; }
[data-testid="stSidebar"] .sidebar-spacer { flex: 1 1 auto; }
[data-testid="stSidebar"] .sidebar-logout { padding: 10px 6px; border-top: 1px solid var(--border); }
[data-testid="stSidebar"] .sidebar-logout button { width: 100%; border-radius: 10px; }
/* Sidebar logo */
.sidebar-logo { display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-bottom: 1px solid var(--border); margin-bottom: 8px; }
.sidebar-logo-icon { width: 32px; height: 32px; border-radius: 8px; overflow: hidden; }
.sidebar-logo-icon img { width: 100%; height: 100%; object-fit: contain; }
.sidebar-logo-text { font-weight: 800; color: var(--text); font-size: 1.1rem; letter-spacing: -0.02em; }
/* Auth card (extra-slim, centered) */
.auth-card { position: relative; padding: 24px; background: rgba(255,255,255,0.85); backdrop-filter: blur(8px); border: 1px solid rgba(22,98,240,0.22); border-radius: 16px; box-shadow: 0 18px 40px rgba(2,6,23,0.10); transition: box-shadow .2s ease, transform .2s ease; max-width: 520px; width: 100%; margin: 0 auto; }
.auth-card.auth-slim { max-width: 420px; }
.auth-card.auth-xs { max-width: 360px; }
.auth-card::before { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 6px; background: linear-gradient(90deg, #22C55E, #16A34A, #0ea5e9); border-top-left-radius: 16px; border-top-right-radius: 16px; }
.auth-card:hover { box-shadow: 0 26px 56px rgba(2,6,23,0.16); transform: translateY(-2px); }
/* Success ribbon shown on login */
.login-success { background: linear-gradient(90deg, #22C55E, #16A34A); color: #fff; border-radius: 12px; padding: 10px 14px; box-shadow: 0 10px 24px rgba(34,197,94,0.35); display: flex; align-items: center; gap: 8px; font-weight: 700; }
.login-success .emoji { filter: drop-shadow(0 4px 8px rgba(0,0,0,0.2)); }
.auth-title { margin: 6px 0 8px 0; font-size: 1.8rem; font-weight: 800; color: var(--text); letter-spacing: -0.02em; text-align: center; }
.muted { color: var(--muted); font-size: 1.0rem; text-align: center; }
/* Inputs: light blue border (global) */
div[data-testid="stTextInput"] input,
div[data-testid="stPassword"] input,
textarea {
border-radius: 10px !important;
border: 1px solid var(--inputBorder) !important;
box-shadow: inset 0 1px 2px rgba(2,6,23,0.04) !important;
background: #FFFFFF !important;
}
/* Prevent outer wrapper hover/focus rings (avoid double boxes) */
div[data-baseweb="input"]:hover, div[data-baseweb="input"]:focus-within,
div[data-baseweb="textarea"]:hover, div[data-baseweb="textarea"]:focus-within,
div[data-testid="stTextInput"] > div:hover, div[data-testid="stTextInput"] > div:focus-within,
div[data-testid="stTextInput"] > div > div:hover, div[data-testid="stTextInput"] > div > div:focus-within,
div[data-testid="stPassword"] > div:hover, div[data-testid="stPassword"] > div:focus-within,
div[data-testid="stPassword"] > div > div:hover, div[data-testid="stPassword"] > div > div:focus-within {
outline: none !important; box-shadow: none !important; border-color: transparent !important;
}
/* Subtle inner hover/focus on the actual field only */
div[data-testid="stTextInput"] input:hover,
div[data-testid="stPassword"] input:hover,
textarea:hover { border-color: var(--inputBorder) !important; box-shadow: inset 0 0 0 1px rgba(22,98,243,0.25) !important; }
div[data-testid="stTextInput"] input:focus,
div[data-testid="stPassword"] input:focus,
textarea:focus { outline: none !important; border-color: var(--inputBorder) !important; box-shadow: inset 0 0 0 1px rgba(22,98,243,0.45) !important; }
/* Constrain Streamlit form width regardless of dynamic class names */
form[class*="stForm"], div[class*="stForm"] { max-width: 760px !important; margin-left: auto !important; margin-right: auto !important; padding-left: 8px !important; padding-right: 8px !important; }
/* Password field styling - expand to match email box and position eye icon */
div[data-testid="stPassword"] { width: 100% !important; }
div[data-testid="stPassword"] input { width: 100% !important; padding-right: 60px !important; }
div[data-testid="stPassword"] button { position: absolute !important; right: 8px !important; top: 50% !important; transform: translateY(-50%) !important; background: none !important; border: none !important; padding: 4px !important; margin: 0 !important; }
div[data-testid="stPassword"] button:hover { background: rgba(0,0,0,0.05) !important; border-radius: 4px !important; }
/* Buttons: global size + hover/transition */
.stButton > button, [data-testid="stDownloadButton"] button { height: 40px; font-size: 0.95rem; border-radius: 10px; transition: transform .15s ease, box-shadow .15s ease; }
.stButton > button:hover, [data-testid="stDownloadButton"] button:hover { transform: translateY(-1px); box-shadow: 0 10px 18px rgba(22,98,243,0.25); }
.auth-card .stCheckbox { font-size: 1.0rem; }
/* Auth buttons inherit global size but keep gradient */
.auth-card .stButton > button { background: linear-gradient(135deg, #22C55E, #16A34A); color: #fff; font-weight: 800; letter-spacing: .2px; border: none; box-shadow: 0 10px 18px rgba(34,197,94,0.28); }
.auth-card .stButton > button:hover { filter: brightness(0.98); }
/* Match info alert with content card look */
div[role="alert"] { background: #F6FAFF !important; border: 1px solid rgba(22,98,243,0.18) !important; color: var(--text) !important; }
/* DataFrame font sizes */
div[data-testid="stDataFrame"] table { font-size: 0.98rem; }
div[data-testid="stDataFrame"] th { font-size: 1.0rem; }
</style>
"""
st.markdown(css, unsafe_allow_html=True)
# Optional login/background image with 50% transparency
if background_url:
# Support @prefix and local files by embedding as base64 when needed
try:
import os, base64
url = background_url.lstrip('@').strip()
if url.startswith('http://') or url.startswith('https://'):
data_url = url
else:
# Treat as local file path
# Map shorthand names to assets/ if needed
if url in {"bg.jpg", "workolik.png"}:
url = os.path.join("assets", url)
if os.path.exists(url):
ext = os.path.splitext(url)[1].lower()
mime = 'image/jpeg' if ext in ['.jpg', '.jpeg'] else 'image/png' if ext == '.png' else 'image/webp' if ext == '.webp' else 'image/*'
with open(url, 'rb') as f:
b64 = base64.b64encode(f.read()).decode()
data_url = f'data:{mime};base64,{b64}'
else:
data_url = url # fallback; let browser try
st.markdown(
f"""
<style>
.stApp::before {{
content: "";
position: fixed;
inset: 0;
z-index: 0;
background-image: url('{data_url}');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
opacity: 0.5; /* 50% transparent */
filter: saturate(110%);
}}
</style>
""",
unsafe_allow_html=True,
)
except Exception:
pass
def render_header(brand_name: str = "Workolik") -> None:
st.markdown(
f"""
<div class="tfw-header">
</div>
""",
unsafe_allow_html=True,
)
def render_topbar(brand_name: str = "") -> None:
st.markdown(
f"""
<div class=\"tfw-topbar\">\n <div class=\"tfw-inner\">\n <span class=\"tfw-logo\"></span>\n <span class=\"tfw-title\">{brand_name}</span>\n </div>\n</div>
""",
unsafe_allow_html=True,
)
def render_sidebar_logo(brand_name: str = "Workolik") -> None:
import streamlit as st
import base64
import os
try:
# Read the image file and encode it as base64
logo_path = os.path.join("assets", "workolik.png")
if os.path.exists(logo_path):
with open(logo_path, "rb") as img_file:
img_data = base64.b64encode(img_file.read()).decode()
st.markdown(
f"""
<div class="sidebar-logo">
<div class="sidebar-logo-icon">
<img src="data:image/png;base64,{img_data}" alt="Workolik Logo" style="width: 100%; height: 100%; object-fit: contain;" />
</div>
<div class="sidebar-logo-text">{brand_name}</div>
</div>
""",
unsafe_allow_html=True,
)
else:
raise FileNotFoundError("Logo file not found")
except Exception as e:
# Fallback to text logo if image fails to load
st.markdown(
f"""
<div class="sidebar-logo">
<div class="sidebar-logo-icon" style="background: linear-gradient(135deg, var(--brand), var(--brandDark)); display: flex; align-items: center; justify-content: center; color: white; font-weight: 700; font-size: 14px;">TW</div>
<div class="sidebar-logo-text">{brand_name}</div>
</div>
""",
unsafe_allow_html=True,
)
def render_store_selector() -> tuple[int | None, str | None]:
"""Render a compact, classy store selector box.
Returns (tenant_id, label). Also persists selection in session_state.
"""
st.markdown(
"""
<div style="
margin: 8px 0 12px 0; padding: 16px 18px;
border: 1px solid var(--border);
background: linear-gradient(135deg,#ffffff, #f8fbff);
border-radius: 16px;
box-shadow: 0 10px 24px rgba(2,6,23,0.06);
">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<div style="display:flex; align-items:center; gap:10px; color: var(--text);">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:999px;background:linear-gradient(135deg,#22C55E,#16A34A);box-shadow:0 4px 10px rgba(34,197,94,0.25);">🛍️</span>
<div style="font-weight: 700; letter-spacing:-0.01em;">Choose the store you want to view</div>
<span style="opacity:0.9">✨</span>
</div>
<div style="font-size:12px;color:#64748b;">👉 Click a card to select</div>
</div>
</div>
""",
unsafe_allow_html=True,
)
# Sub caption + clear selection
current_label = st.session_state.get("store_label")
left_cap, right_clear = st.columns([6, 1])
with left_cap:
st.caption("Please choose a store before surfing !")
if current_label:
st.caption(f"Selected: {current_label}")
with right_clear:
if st.button("Clear", key="clear_store_sel"):
st.session_state["tenant_id"] = None
st.session_state["store_label"] = None
st.experimental_rerun() if hasattr(st, "experimental_rerun") else st.rerun()
# We no longer use query params; selection happens in-session only
chosen_from_query: str | None = None
# Grid of store boxes (soft gradient cards with per-store colors and emojis)
emoji_map = {"PC": "🍷", "IP": "🍻", "ML": "🥂", "CL4": "🍸", "NL": "🥃", "CL6": "🍾", "RC": "🍹", "PL": "🍺"}
color_rgb = {"PC": (37,99,235), "IP": (22,163,74), "ML": (245,158,11), "CL4": (220,38,38), "NL": (124,58,237), "CL6": (234,179,8), "RC": (6, 182, 212), "PL": (236, 72, 153)}
preselect_label = st.session_state.get("store_label")
chosen_label = None
# No search box; show all stores
filtered_stores = STORES
# Always render 3 columns per row (e.g., 3 + 3 for 6 stores)
rows = [filtered_stores[i:i+3] for i in range(0, len(filtered_stores), 3)]
for row in rows:
cols = st.columns(3)
for i, store in enumerate(row):
with cols[i]:
icon = emoji_map.get(store["code"], "🏬")
r, g, b = color_rgb.get(store["code"], (14,165,233))
is_selected = (preselect_label == store["label"]) or (chosen_from_query == store["label"]) # highlight current
border_alpha = 0.48 if is_selected else 0.28
shadow = "0 18px 42px rgba(2,6,23,0.16)" if is_selected else "0 12px 28px rgba(2,6,23,0.10)"
border_width = "2px" if is_selected else "1px"
check = "" if is_selected else ""
# Render a card-like button that sets selection without changing URL
clicked = st.button(
f"{icon} {store['label']}{check}",
key=f"store_card_{store['code']}",
use_container_width=True,
type="secondary",
)
# Lightweight card styling via inline CSS targeting this button
st.markdown(
f"""
<style>
div[data-testid='stButton'] button#store_card_{store['code']} {{
background: linear-gradient(135deg, rgba({r},{g},{b},0.12), rgba({r},{g},{b},0.20));
border: {border_width} solid rgba({r},{g},{b},{border_alpha});
border-radius: 18px; padding: 18px; box-shadow: {shadow};
color: #0F172A; font-weight: 800; text-align: left;
}}
div[data-testid='stButton'] button#store_card_{store['code']}:hover {{
transform: translateY(-2px); box-shadow: 0 22px 48px rgba(2,6,23,0.18);
}}
</style>
""",
unsafe_allow_html=True,
)
if clicked:
st.session_state["tenant_id"] = store["tenant_id"]
st.session_state["store_label"] = store["label"]
chosen_label = store["label"]
st.rerun()
# Resolve tenant_id
effective_label = chosen_label or preselect_label
selected = next((s for s in STORES if s["label"] == effective_label), None)
tenant_id = selected["tenant_id"] if selected else None
# Persist
st.session_state["tenant_id"] = tenant_id
st.session_state["store_label"] = selected["label"] if selected else None
st.session_state["division_code"] = None
return tenant_id, (selected["label"] if selected else None)