feat: Implement email sending utilities and templates for job notifications
Some checks failed
CI/CD Pipeline / test (push) Failing after 4m9s
Some checks failed
CI/CD Pipeline / test (push) Failing after 4m9s
- Added email_service.py for sending emails with SMTP configuration. - Introduced email_templates.py to render job alert email subjects and bodies. - Enhanced scraper.py to extract contact information from job listings. - Updated settings.js to handle negative keyword input validation. - Created email.html and email_templates.html for managing email subscriptions and templates in the admin interface. - Modified base.html to include links for email alerts and templates. - Expanded user settings.html to allow management of negative keywords. - Updated utils.py to include functions for retrieving negative keywords and email settings. - Enhanced job filtering logic to exclude jobs containing negative keywords.
This commit is contained in:
420
web/db.py
420
web/db.py
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
Tables:
|
||||
- users(user_id PK, username UNIQUE, created_at)
|
||||
- job_listings(job_id PK, url UNIQUE, region, keyword, title, pay, location, timestamp)
|
||||
- job_descriptions(job_id PK FK -> job_listings, title, company, location, description, posted_time, url)
|
||||
- job_descriptions(job_id PK FK -> job_listings, title, company, location, description, posted_time, url, reply_url)
|
||||
- user_interactions(job_id PK FK -> job_listings, user_id FK -> users, seen_at, url_visited, is_user_favorite)
|
||||
- regions(region_id PK, name UNIQUE)
|
||||
- keywords(keyword_id PK, name UNIQUE)
|
||||
@@ -16,6 +16,7 @@ Tables:
|
||||
|
||||
from datetime import datetime, UTC
|
||||
from typing import Optional, Dict, Any, List
|
||||
import re
|
||||
from web.utils import (
|
||||
get_color_from_string,
|
||||
url_to_job_id,
|
||||
@@ -96,10 +97,279 @@ class JobDescription(Base):
|
||||
description = Column(Text)
|
||||
posted_time = Column(String(TIME_LEN))
|
||||
url = Column(String(URL_LEN))
|
||||
reply_url = Column(String(URL_LEN))
|
||||
contact_email = Column(String(SHORT_LEN))
|
||||
contact_phone = Column(String(SHORT_LEN))
|
||||
contact_name = Column(String(SHORT_LEN))
|
||||
|
||||
listing = relationship("JobListing", back_populates="description")
|
||||
|
||||
|
||||
def _normalize_email(value: Optional[str]) -> str:
|
||||
if not value or not isinstance(value, str):
|
||||
return ""
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
def subscribe_email(email: str) -> bool:
|
||||
"""Add or reactivate an email subscription."""
|
||||
address = _normalize_email(email)
|
||||
if not address:
|
||||
raise ValueError("email address required")
|
||||
with _ensure_session() as session:
|
||||
existing = session.execute(
|
||||
text(
|
||||
"SELECT subscription_id, is_active FROM email_subscriptions WHERE email = :e"
|
||||
),
|
||||
{"e": address},
|
||||
).fetchone()
|
||||
now = datetime.now(UTC)
|
||||
if existing:
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE email_subscriptions SET is_active = 1, updated_at = :u WHERE subscription_id = :sid"
|
||||
),
|
||||
{"u": now, "sid": existing[0]},
|
||||
)
|
||||
else:
|
||||
session.execute(
|
||||
text(
|
||||
"INSERT INTO email_subscriptions(email, is_active, created_at, updated_at) "
|
||||
"VALUES(:e, 1, :u, :u)"
|
||||
),
|
||||
{"e": address, "u": now},
|
||||
)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
|
||||
def unsubscribe_email(email: str) -> bool:
|
||||
"""Deactivate an email subscription."""
|
||||
address = _normalize_email(email)
|
||||
if not address:
|
||||
raise ValueError("email address required")
|
||||
with _ensure_session() as session:
|
||||
now = datetime.now(UTC)
|
||||
result = session.execute(
|
||||
text(
|
||||
"UPDATE email_subscriptions SET is_active = 0, updated_at = :u WHERE email = :e"
|
||||
),
|
||||
{"u": now, "e": address},
|
||||
)
|
||||
session.commit()
|
||||
rowcount = getattr(result, "rowcount", None)
|
||||
if rowcount is None:
|
||||
return False
|
||||
return rowcount > 0
|
||||
|
||||
|
||||
def list_email_subscriptions(*, active_only: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Return subscription rows as dicts."""
|
||||
query = "SELECT subscription_id, email, is_active, created_at, updated_at FROM email_subscriptions"
|
||||
params: Dict[str, Any] = {}
|
||||
if active_only:
|
||||
query += " WHERE is_active = 1"
|
||||
query += " ORDER BY email"
|
||||
with _ensure_session() as session:
|
||||
rows = session.execute(text(query), params).fetchall()
|
||||
result: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
result.append(
|
||||
{
|
||||
"subscription_id": row[0],
|
||||
"email": row[1],
|
||||
"is_active": bool(row[2]),
|
||||
"created_at": row[3],
|
||||
"updated_at": row[4],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def get_active_email_recipients() -> List[str]:
|
||||
"""Return list of active subscription email addresses."""
|
||||
return [s["email"] for s in list_email_subscriptions(active_only=True)]
|
||||
|
||||
|
||||
def _normalize_slug(value: Optional[str]) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
slug = re.sub(r"[^a-zA-Z0-9-]+", "-", value.strip().lower())
|
||||
slug = re.sub(r"-+", "-", slug).strip("-")
|
||||
return slug
|
||||
|
||||
|
||||
def _template_to_dict(template: EmailTemplate) -> Dict[str, Any]:
|
||||
created = getattr(template, "created_at", None)
|
||||
updated = getattr(template, "updated_at", None)
|
||||
return {
|
||||
"template_id": template.template_id,
|
||||
"slug": template.slug,
|
||||
"name": template.name,
|
||||
"subject": template.subject,
|
||||
"body": template.body,
|
||||
"is_active": bool(template.is_active),
|
||||
"created_at": created.isoformat() if isinstance(created, datetime) else created,
|
||||
"updated_at": updated.isoformat() if isinstance(updated, datetime) else updated,
|
||||
}
|
||||
|
||||
|
||||
def list_email_templates(*, include_inactive: bool = True) -> List[Dict[str, Any]]:
|
||||
with _ensure_session() as session:
|
||||
query = session.query(EmailTemplate)
|
||||
if not include_inactive:
|
||||
query = query.filter(EmailTemplate.is_active.is_(True))
|
||||
items = query.order_by(EmailTemplate.name.asc()).all()
|
||||
return [_template_to_dict(obj) for obj in items]
|
||||
|
||||
|
||||
def get_email_template(template_id: int) -> Optional[Dict[str, Any]]:
|
||||
if not template_id:
|
||||
return None
|
||||
with _ensure_session() as session:
|
||||
obj = session.get(EmailTemplate, int(template_id))
|
||||
return _template_to_dict(obj) if obj else None
|
||||
|
||||
|
||||
def get_email_template_by_slug(slug: str) -> Optional[Dict[str, Any]]:
|
||||
normalized = _normalize_slug(slug)
|
||||
if not normalized:
|
||||
return None
|
||||
with _ensure_session() as session:
|
||||
obj = session.query(EmailTemplate).filter(
|
||||
EmailTemplate.slug == normalized).one_or_none()
|
||||
return _template_to_dict(obj) if obj else None
|
||||
|
||||
|
||||
def create_email_template(
|
||||
*,
|
||||
name: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
slug: Optional[str] = None,
|
||||
is_active: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
name_clean = (name or "").strip()
|
||||
if not name_clean:
|
||||
raise ValueError("Template name is required")
|
||||
subject_clean = (subject or "").strip()
|
||||
if not subject_clean:
|
||||
raise ValueError("Template subject is required")
|
||||
body_clean = (body or "").strip()
|
||||
if not body_clean:
|
||||
raise ValueError("Template body is required")
|
||||
|
||||
slug_clean = _normalize_slug(slug or name_clean)
|
||||
if not slug_clean:
|
||||
raise ValueError("Template slug is required")
|
||||
|
||||
with _ensure_session() as session:
|
||||
existing = session.query(EmailTemplate).filter(
|
||||
EmailTemplate.slug == slug_clean).one_or_none()
|
||||
if existing:
|
||||
raise ValueError("A template with this slug already exists")
|
||||
template = EmailTemplate(
|
||||
name=name_clean,
|
||||
slug=slug_clean,
|
||||
subject=subject_clean,
|
||||
body=body_clean,
|
||||
is_active=bool(is_active),
|
||||
)
|
||||
session.add(template)
|
||||
session.commit()
|
||||
session.refresh(template)
|
||||
return _template_to_dict(template)
|
||||
|
||||
|
||||
def update_email_template(
|
||||
template_id: int,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
body: Optional[str] = None,
|
||||
slug: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if not template_id:
|
||||
raise ValueError("template_id is required")
|
||||
with _ensure_session() as session:
|
||||
template = session.get(EmailTemplate, int(template_id))
|
||||
if template is None:
|
||||
raise ValueError("Template not found")
|
||||
if name is not None:
|
||||
name_clean = name.strip()
|
||||
if not name_clean:
|
||||
raise ValueError("Template name is required")
|
||||
setattr(template, "name", name_clean)
|
||||
if subject is not None:
|
||||
subject_clean = subject.strip()
|
||||
if not subject_clean:
|
||||
raise ValueError("Template subject is required")
|
||||
setattr(template, "subject", subject_clean)
|
||||
if body is not None:
|
||||
body_clean = body.strip()
|
||||
if not body_clean:
|
||||
raise ValueError("Template body is required")
|
||||
setattr(template, "body", body_clean)
|
||||
if slug is not None:
|
||||
slug_clean = _normalize_slug(slug)
|
||||
if not slug_clean:
|
||||
raise ValueError("Template slug is required")
|
||||
existing = (
|
||||
session.query(EmailTemplate)
|
||||
.filter(EmailTemplate.slug == slug_clean, EmailTemplate.template_id != template.template_id)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing:
|
||||
raise ValueError("A template with this slug already exists")
|
||||
setattr(template, "slug", slug_clean)
|
||||
if is_active is not None:
|
||||
setattr(template, "is_active", bool(is_active))
|
||||
template.touch()
|
||||
session.commit()
|
||||
session.refresh(template)
|
||||
return _template_to_dict(template)
|
||||
|
||||
|
||||
def delete_email_template(template_id: int) -> bool:
|
||||
if not template_id:
|
||||
return False
|
||||
with _ensure_session() as session:
|
||||
template = session.get(EmailTemplate, int(template_id))
|
||||
if template is None:
|
||||
return False
|
||||
session.delete(template)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
|
||||
def ensure_default_email_template() -> None:
|
||||
try:
|
||||
from web.email_templates import DEFAULT_JOB_ALERT_SUBJECT, DEFAULT_JOB_ALERT_BODY
|
||||
except Exception:
|
||||
DEFAULT_JOB_ALERT_SUBJECT = "{count_label}{scope}"
|
||||
DEFAULT_JOB_ALERT_BODY = (
|
||||
"Hi,\n\n{intro_line}\n{jobs_message}\n\nGenerated at {timestamp} UTC.\n"
|
||||
"You are receiving this message because job alerts are enabled.\n"
|
||||
)
|
||||
try:
|
||||
with _ensure_session() as session:
|
||||
existing = session.query(EmailTemplate).filter(
|
||||
EmailTemplate.slug == "job-alert").one_or_none()
|
||||
if existing is None:
|
||||
template = EmailTemplate(
|
||||
name="Job Alert",
|
||||
slug="job-alert",
|
||||
subject=DEFAULT_JOB_ALERT_SUBJECT,
|
||||
body=DEFAULT_JOB_ALERT_BODY,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(template)
|
||||
session.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class UserInteraction(Base):
|
||||
__tablename__ = "user_interactions"
|
||||
# composite uniqueness on (user_id, job_id)
|
||||
@@ -146,6 +416,20 @@ class UserKeyword(Base):
|
||||
"keywords.keyword_id", ondelete="CASCADE"), primary_key=True)
|
||||
|
||||
|
||||
class NegativeKeyword(Base):
|
||||
__tablename__ = "negative_keywords"
|
||||
keyword_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(SHORT_LEN), unique=True, nullable=False)
|
||||
|
||||
|
||||
class UserNegativeKeyword(Base):
|
||||
__tablename__ = "user_negative_keywords"
|
||||
user_id = Column(Integer, ForeignKey(
|
||||
"users.user_id", ondelete="CASCADE"), primary_key=True)
|
||||
keyword_id = Column(Integer, ForeignKey(
|
||||
"negative_keywords.keyword_id", ondelete="CASCADE"), primary_key=True)
|
||||
|
||||
|
||||
class Log(Base):
|
||||
__tablename__ = "logs"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
@@ -155,6 +439,35 @@ class Log(Base):
|
||||
fetched_at = Column(DateTime)
|
||||
|
||||
|
||||
class EmailSubscription(Base):
|
||||
__tablename__ = "email_subscriptions"
|
||||
subscription_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
email = Column(String(SHORT_LEN), unique=True, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
def touch(self):
|
||||
setattr(self, "updated_at", datetime.utcnow())
|
||||
|
||||
|
||||
class EmailTemplate(Base):
|
||||
__tablename__ = "email_templates"
|
||||
template_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
slug = Column(String(SHORT_LEN), unique=True, nullable=False)
|
||||
name = Column(String(SHORT_LEN), nullable=False)
|
||||
subject = Column(Text, nullable=False)
|
||||
body = Column(Text, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(
|
||||
DateTime, default=lambda: datetime.now(UTC), nullable=False)
|
||||
updated_at = Column(
|
||||
DateTime, default=lambda: datetime.now(UTC), nullable=False)
|
||||
|
||||
def touch(self):
|
||||
setattr(self, "updated_at", datetime.now(UTC))
|
||||
|
||||
|
||||
def _ensure_session() -> Session:
|
||||
global engine, SessionLocal
|
||||
if engine is None or SessionLocal is None:
|
||||
@@ -202,6 +515,31 @@ def db_init():
|
||||
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login DATETIME NULL"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute(text(
|
||||
"ALTER TABLE job_descriptions ADD COLUMN IF NOT EXISTS reply_url VARCHAR(512) NULL"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute(text(
|
||||
"ALTER TABLE job_descriptions ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255) NULL"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute(text(
|
||||
"ALTER TABLE job_descriptions ADD COLUMN IF NOT EXISTS contact_phone VARCHAR(255) NULL"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute(text(
|
||||
"ALTER TABLE job_descriptions ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255) NULL"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
ensure_default_email_template()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def upsert_user_interaction(job_id: str | int, *, user_id: Optional[int] = None, seen_at: Optional[str] = None, url_visited: Optional[str] = None, is_user_favorite: Optional[bool] = None):
|
||||
@@ -279,6 +617,9 @@ def upsert_job_details(job_data: Dict[str, Any], region: str = "", keyword: str
|
||||
the function will skip updating to avoid unnecessary work.
|
||||
- On successful upsert, a log entry is recorded with `insert_log(url, ...)`.
|
||||
"""
|
||||
if not job_data or job_data.get("is_negative_match"):
|
||||
return
|
||||
|
||||
url = job_data.get("url")
|
||||
job_id = normalize_job_id(job_data.get("id"), url)
|
||||
if not job_id:
|
||||
@@ -303,6 +644,10 @@ def upsert_job_details(job_data: Dict[str, Any], region: str = "", keyword: str
|
||||
location = job_data.get("location") or None
|
||||
description = job_data.get("description") or None
|
||||
posted_time = job_data.get("posted_time") or None
|
||||
reply_url = job_data.get("reply_url") or None
|
||||
contact_email = job_data.get("contact_email") or None
|
||||
contact_phone = job_data.get("contact_phone") or None
|
||||
contact_name = job_data.get("contact_name") or None
|
||||
|
||||
job_id = str(job_id)
|
||||
with _ensure_session() as session:
|
||||
@@ -316,6 +661,10 @@ def upsert_job_details(job_data: Dict[str, Any], region: str = "", keyword: str
|
||||
setattr(obj, "description", description)
|
||||
setattr(obj, "posted_time", posted_time)
|
||||
setattr(obj, "url", url)
|
||||
setattr(obj, "reply_url", reply_url)
|
||||
setattr(obj, "contact_email", contact_email)
|
||||
setattr(obj, "contact_phone", contact_phone)
|
||||
setattr(obj, "contact_name", contact_name)
|
||||
session.commit()
|
||||
# Record that we fetched/updated this job page
|
||||
try:
|
||||
@@ -627,6 +976,27 @@ def upsert_keyword(name: str) -> int:
|
||||
return upsert_keyword(name)
|
||||
|
||||
|
||||
def upsert_negative_keyword(name: str) -> int:
|
||||
"""Get or create a negative keyword by name; return keyword_id."""
|
||||
name = (name or "").strip().lower()
|
||||
if not name:
|
||||
raise ValueError("Negative keyword cannot be empty")
|
||||
with _ensure_session() as session:
|
||||
row = session.execute(text("SELECT keyword_id FROM negative_keywords WHERE name = :n"), {
|
||||
"n": name}).fetchone()
|
||||
if row:
|
||||
return int(row[0])
|
||||
session.execute(
|
||||
text("INSERT INTO negative_keywords(name) VALUES (:n)"), {"n": name})
|
||||
session.commit()
|
||||
with _ensure_session() as session:
|
||||
row2 = session.execute(text("SELECT keyword_id FROM negative_keywords WHERE name = :n"), {
|
||||
"n": name}).fetchone()
|
||||
if row2:
|
||||
return int(row2[0])
|
||||
return upsert_negative_keyword(name)
|
||||
|
||||
|
||||
def set_user_regions(username: str, region_names: List[str]) -> None:
|
||||
"""Replace user's preferred regions with given names."""
|
||||
user_id = get_or_create_user(username)
|
||||
@@ -685,6 +1055,34 @@ def set_user_keywords(username: str, keyword_names: List[str]) -> None:
|
||||
session.commit()
|
||||
|
||||
|
||||
def set_user_negative_keywords(username: str, keyword_names: List[str]) -> None:
|
||||
"""Replace user's negative keywords with given names."""
|
||||
user_id = get_or_create_user(username)
|
||||
names = sorted({(n or "").strip().lower()
|
||||
for n in keyword_names if (n or "").strip()})
|
||||
keyword_ids: List[int] = [upsert_negative_keyword(n) for n in names]
|
||||
if not keyword_ids and not names:
|
||||
with _ensure_session() as session:
|
||||
session.execute(
|
||||
text("DELETE FROM user_negative_keywords WHERE user_id = :u"), {"u": user_id})
|
||||
session.commit()
|
||||
return
|
||||
desired = set(keyword_ids)
|
||||
with _ensure_session() as session:
|
||||
rows = session.execute(text("SELECT keyword_id FROM user_negative_keywords WHERE user_id = :u"), {
|
||||
"u": user_id}).fetchall()
|
||||
current = set(int(r[0]) for r in rows)
|
||||
to_add = desired - current
|
||||
to_remove = current - desired
|
||||
for kid in to_remove:
|
||||
session.execute(text("DELETE FROM user_negative_keywords WHERE user_id = :u AND keyword_id = :k"), {
|
||||
"u": user_id, "k": int(kid)})
|
||||
for kid in to_add:
|
||||
session.execute(text("INSERT INTO user_negative_keywords(user_id, keyword_id) VALUES(:u, :k)"), {
|
||||
"u": user_id, "k": int(kid)})
|
||||
session.commit()
|
||||
|
||||
|
||||
def get_user_regions(username: str) -> List[Dict[str, str]]:
|
||||
"""Return preferred region names for a user (empty if none)."""
|
||||
with _ensure_session() as session:
|
||||
@@ -725,6 +1123,26 @@ def get_user_keywords(username: str) -> List[Dict[str, str]]:
|
||||
return [{"name": r[0], "color": r[1]} for r in rows]
|
||||
|
||||
|
||||
def get_user_negative_keywords(username: str) -> List[str]:
|
||||
"""Return negative keyword names for a user (empty if none)."""
|
||||
with _ensure_session() as session:
|
||||
row = session.execute(text("SELECT user_id FROM users WHERE username = :u"), {
|
||||
"u": username}).fetchone()
|
||||
if not row:
|
||||
return []
|
||||
user_id = int(row[0])
|
||||
rows = session.execute(text(
|
||||
"""
|
||||
SELECT k.name
|
||||
FROM negative_keywords k
|
||||
INNER JOIN user_negative_keywords uk ON uk.keyword_id = k.keyword_id
|
||||
WHERE uk.user_id = :u
|
||||
ORDER BY k.name ASC
|
||||
"""
|
||||
), {"u": user_id}).fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def get_all_regions() -> List[Dict[str, str]]:
|
||||
"""Return all region names from regions table (sorted)."""
|
||||
with _ensure_session() as session:
|
||||
|
||||
Reference in New Issue
Block a user