bottleneckcrm/backend/common/tasks.py
2026-10-21 08:26:49 +05:30

472 lines
16 KiB
Python

import logging
from datetime import timedelta
from botocore.exceptions import ClientError
from celery import shared_task
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.mail import EmailMessage
from django.core.validators import validate_email
from django.db import connection
from django.template.loader import render_to_string
from django.utils import timezone
from common.links import frontend_url
from common.models import (
MagicLinkToken,
Notification,
Org,
PortalLoginToken,
Profile,
Teams,
User,
)
from contacts.models import Contact
logger = logging.getLogger(__name__)
def set_rls_context(org_id):
"""
Set RLS context for Celery tasks that query org-scoped tables.
Celery workers don't go through Django middleware, so RLS context
must be set explicitly before querying org-scoped data.
Args:
org_id: Organization UUID (string or UUID object)
"""
# SQLite (test backend) has no set_config(). RLS is a Postgres feature.
if connection.vendor != "postgresql":
return
if org_id:
with connection.cursor() as cursor:
cursor.execute(
"SELECT set_config('app.current_org', %s, false)", [str(org_id)]
)
def clear_rls_context():
"""Drop the session-scoped RLS context set by `set_rls_context`.
Between worker tasks this is a second guard: Celery's Django fixup already
closes every connection around each task (test_periodic_tasks_rls.py). It is
the only guard where the connection lives on, such as a streamed response
that runs after the middleware's reset.
"""
if connection.vendor != "postgresql":
return
with connection.cursor() as cursor:
cursor.execute("SELECT set_config('app.current_org', '', false)")
@shared_task
def send_welcome_email(user_id):
"""Send welcome email to newly created users."""
user_obj = User.objects.filter(id=user_id).first()
if not user_obj:
return
email = user_obj.email.strip()
try:
validate_email(email)
except ValidationError:
logger.warning("Welcome email skipped: invalid email for user %s", user_id)
return
context = {"url": settings.FRONTEND_URL}
subject = "Welcome to BottleCRM"
html_content = render_to_string("welcome_email.html", context=context)
msg = EmailMessage(
subject,
html_content,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[email],
)
msg.content_subtype = "html"
try:
msg.send()
except (ClientError, OSError):
logger.exception("Failed to send welcome email for user %s", user_id)
@shared_task
def send_magic_link_email(token_id, raw_code=None):
"""Send a magic-link or OTP-code email for passwordless authentication.
For `delivery == "code"` rows, the caller passes `raw_code` (the plaintext
OTP), only the hash is stored on the token row, so the plaintext can't be
recovered from the DB by this task.
"""
magic_token = MagicLinkToken.objects.filter(id=token_id).first()
if not magic_token:
return
email = magic_token.email.strip()
try:
validate_email(email)
except ValidationError:
logger.warning(
"Magic link skipped: invalid email format for token %s", token_id
)
return
if magic_token.delivery == "code":
if not raw_code:
logger.warning(
"Magic link skipped: raw_code missing for code-delivery token %s",
token_id,
)
return
subject = f"Your BottleCRM sign-in code: {raw_code}"
html_content = render_to_string(
"magic_link_code_email.html",
{"code": raw_code},
)
else:
magic_link_url = (
f"{settings.FRONTEND_URL}/login/verify?token={magic_token.token}"
)
subject = "Your BottleCRM sign-in link"
html_content = render_to_string(
"magic_link_email.html",
{"magic_link_url": magic_link_url},
)
msg = EmailMessage(
subject,
html_content,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[email],
)
msg.content_subtype = "html"
try:
msg.send()
except (ClientError, OSError):
logger.exception("Failed to send email for magic link token %s", token_id)
@shared_task
def send_portal_login_email(token_id, raw_code):
"""Deliver a customer portal sign-in code.
Runs in a worker, so it sets its own RLS context before reading the
org-scoped contact. The org comes off the token row, which is unscoped and
therefore readable with no context at all; that is the whole reason
PortalLoginToken is not in ORG_SCOPED_TABLES.
The code is passed in rather than read from the row because the row only
ever holds its hash.
"""
if not raw_code:
logger.warning("Portal login skipped: no code for token %s", token_id)
return None
token_obj = PortalLoginToken.objects.filter(id=token_id).first()
if not token_obj:
return None
set_rls_context(str(token_obj.org_id))
contact = Contact.objects.filter(id=token_obj.contact_id).first()
if contact is None or not contact.email:
logger.warning("Portal login skipped: no contact email for token %s", token_id)
return None
email = contact.email.strip()
try:
validate_email(email)
except ValidationError:
logger.warning(
"Portal login skipped: invalid email format for token %s", token_id
)
return None
org_name = token_obj.org.name or ""
html_content = render_to_string(
"portal/login_email.html",
{
"org_name": org_name,
"contact_name": contact.first_name or "",
"code": raw_code,
},
)
msg = EmailMessage(
# The code is in the subject so it shows in a phone's notification
# preview, which is where the customer is reading it. Same trade the
# internal magic-link mail already makes.
f"Your sign-in code: {raw_code}",
html_content,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[email],
)
msg.content_subtype = "html"
try:
msg.send()
except (ClientError, OSError):
logger.exception("Failed to send email for portal login token %s", token_id)
return str(token_obj.id)
@shared_task
def send_email_user_status(
user_id,
status_changed_user="",
):
"""Send Mail To Users Regarding their status i.e active or inactive"""
user = User.objects.filter(id=user_id).first()
if user:
context = {}
context["message"] = "deactivated"
context["email"] = user.email
# The web app's root is the dashboard. There is no `/marketing` route,
# and `has_marketing_access` is a profile flag rather than a user one,
# so the branch that appended it could never have been reached from a
# `User` in the first place.
context["url"] = frontend_url("/")
if user.is_active:
context["message"] = "activated"
context["status_changed_user"] = status_changed_user
if context["message"] == "activated":
subject = "Account Activated "
html_content = render_to_string(
"user_status_activate.html", context=context
)
else:
subject = "Account Deactivated "
html_content = render_to_string(
"user_status_deactivate.html", context=context
)
recipients = []
recipients.append(user.email)
if recipients:
msg = EmailMessage(
subject,
html_content,
from_email=settings.DEFAULT_FROM_EMAIL,
to=recipients,
)
msg.content_subtype = "html"
msg.send()
@shared_task
def send_email_user_delete(
user_email,
deleted_by="",
):
"""Send Mail To Users When their account is deleted"""
if user_email:
context = {}
context["message"] = "deleted"
context["deleted_by"] = deleted_by
context["email"] = user_email
recipients = []
recipients.append(user_email)
subject = "CRM : Your account is Deleted. "
html_content = render_to_string("user_delete_email.html", context=context)
if recipients:
msg = EmailMessage(
subject,
html_content,
from_email=settings.DEFAULT_FROM_EMAIL,
to=recipients,
)
msg.content_subtype = "html"
msg.send()
@shared_task
def remove_users(removed_users_list, team_id, org_id=None):
# Set RLS context for org-scoped queries
set_rls_context(org_id)
removed_users_list = [i for i in removed_users_list if i.isdigit()]
users_list = Profile.objects.filter(id__in=removed_users_list)
if users_list.exists():
team = Teams.objects.filter(id=team_id).first()
if team:
accounts = team.account_teams.all()
for account in accounts:
for user in users_list:
account.assigned_to.remove(user)
contacts = team.contact_teams.all()
for contact in contacts:
for user in users_list:
contact.assigned_to.remove(user)
leads = team.lead_teams.all()
for lead in leads:
for user in users_list:
lead.assigned_to.remove(user)
opportunities = team.oppurtunity_teams.all()
for opportunity in opportunities:
for user in users_list:
opportunity.assigned_to.remove(user)
cases = team.cases_teams.all()
for case in cases:
for user in users_list:
case.assigned_to.remove(user)
docs = team.document_teams.all()
for doc in docs:
for user in users_list:
doc.shared_to.remove(user)
tasks = team.tasks_teams.all()
for task in tasks:
for user in users_list:
task.assigned_to.remove(user)
invoices = team.invoices_teams.all()
for invoice in invoices:
for user in users_list:
invoice.assigned_to.remove(user)
@shared_task
def update_team_users(team_id, org_id=None):
"""this function updates assigned_to field on all models when a team is updated"""
# Set RLS context for org-scoped queries
set_rls_context(org_id)
team = Teams.objects.filter(id=team_id).first()
if team:
teams_members = team.users.all()
accounts = team.account_teams.all()
for account in accounts:
account_assigned_to_users = account.assigned_to.all()
for team_member in teams_members:
if team_member not in account_assigned_to_users:
account.assigned_to.add(team_member)
contacts = team.contact_teams.all()
for contact in contacts:
contact_assigned_to_users = contact.assigned_to.all()
for team_member in teams_members:
if team_member not in contact_assigned_to_users:
contact.assigned_to.add(team_member)
leads = team.lead_teams.all()
for lead in leads:
lead_assigned_to_users = lead.assigned_to.all()
for team_member in teams_members:
if team_member not in lead_assigned_to_users:
lead.assigned_to.add(team_member)
opportunities = team.oppurtunity_teams.all()
for opportunity in opportunities:
opportunity_assigned_to_users = opportunity.assigned_to.all()
for team_member in teams_members:
if team_member not in opportunity_assigned_to_users:
opportunity.assigned_to.add(team_member)
cases = team.cases_teams.all()
for case in cases:
case_assigned_to_users = case.assigned_to.all()
for team_member in teams_members:
if team_member not in case_assigned_to_users:
case.assigned_to.add(team_member)
docs = team.document_teams.all()
for doc in docs:
doc_assigned_to_users = doc.shared_to.all()
for team_member in teams_members:
if team_member not in doc_assigned_to_users:
doc.shared_to.add(team_member)
tasks = team.tasks_teams.all()
for task in tasks:
task_assigned_to_users = task.assigned_to.all()
for team_member in teams_members:
if team_member not in task_assigned_to_users:
task.assigned_to.add(team_member)
invoices = team.invoices_teams.all()
for invoice in invoices:
invoice_assigned_to_users = invoice.assigned_to.all()
for team_member in teams_members:
if team_member not in invoice_assigned_to_users:
invoice.assigned_to.add(team_member)
# Default cutoff for purging read notifications. Per
# `docs/cases/tier2/in-app-notifications.md` "Storage growth".
NOTIFICATION_PURGE_DAYS = 90
@shared_task
def purge_read_notifications(days=NOTIFICATION_PURGE_DAYS):
"""Delete already-read notifications older than ``days`` days.
Schedule via celery-beat (recommended cadence: nightly). Walks orgs and sets
the RLS context for each, because `notification` is org-scoped.
THIS USED TO DELETE NOTHING IN PRODUCTION. The previous version ran one
unscoped DELETE and justified it in its own docstring: "RLS does not need a
per-org context here because the query targets `read_at`, which is intrinsic
to the row". That is not how RLS works. A policy filters which rows the
statement can see at all, whatever the WHERE clause names, and the isolation
policy is `org_id::text = NULLIF(current_setting('app.current_org', true),
'')`, which matches nothing when the context is empty. A Celery worker runs
no middleware, so the context was always empty, and under the non-superuser
role the deployment docs mandate, the nightly DELETE removed zero rows every
night while logging nothing (it only logs when `deleted` is truthy).
It passed its tests because `crm.test_settings` is SQLite, which has no RLS
at all. `set_rls_context` returns early on any non-Postgres vendor, so the
per-org loop is a no-op there and the tests still describe real behaviour on
both backends.
The context is cleared at the end. It is set at SESSION scope, so leaving
the last org's id on a pooled connection would hand that tenant's context to
whatever borrows the connection next.
"""
cutoff = timezone.now() - timedelta(days=days)
deleted = 0
try:
for org_id in Org.objects.values_list("id", flat=True).iterator():
set_rls_context(org_id)
removed, _ = Notification.objects.filter(
read_at__isnull=False, read_at__lt=cutoff
).delete()
deleted += removed
finally:
clear_rls_context()
if deleted:
logger.info("Purged %s read notifications older than %s days", deleted, days)
return deleted
@shared_task
def flush_expired_refresh_tokens():
"""Delete refresh-token bookkeeping rows whose tokens have already expired.
`OrgAwareTokenRefreshView` rotates refresh tokens, so simplejwt writes one
`OutstandingToken` row per token minted and one `BlacklistedToken` row per
rotation. An expired token is rejected on its `exp` claim regardless of
blacklist state, so those rows stop carrying security value once
`expires_at` passes, and would otherwise grow unbounded, one row per login
and per refresh. Deleting the outstanding row cascades to its blacklist
entry.
Equivalent to simplejwt's `manage.py flushexpiredtokens`; schedule via
celery-beat (nightly is plenty). Not org-scoped; `expires_at` is intrinsic
to the row, so no RLS context is needed.
Returns the number of outstanding-token rows removed.
"""
from rest_framework_simplejwt.token_blacklist.models import OutstandingToken
expired = OutstandingToken.objects.filter(expires_at__lte=timezone.now())
count = expired.count()
if count:
expired.delete()
logger.info("Flushed %s expired refresh token records", count)
return count