Implement Emailing, Implement Add-User Flow

This commit is contained in:
Phil Szalay 2025-02-19 12:22:10 +01:00
parent 7ad498bfda
commit ff68a9ae80
13 changed files with 320 additions and 46 deletions

View File

@ -9,6 +9,7 @@ from open_webui.internal.db import get_db, Base
# Constants
NO_COMPANY = "NO_COMPANY"
EIGHTY_PERCENT_CREDIT_LIMIT = 4000
####################
# Company DB Schema
@ -179,9 +180,4 @@ class CompanyTable:
company = db.query(Company).filter(Company.id == company_id).first()
return company.credit_balance if company else None
def has_sufficient_credits(self, company_id: str, required_credits: int) -> bool:
"""Check if company has sufficient credits for an operation"""
balance = self.get_credit_balance(company_id)
return balance is None or balance >= required_credits # None means unlimited
Companies = CompanyTable()

View File

@ -3,6 +3,8 @@ import uuid
import time
import datetime
import logging
import secrets
import string
from aiohttp import ClientSession
from open_webui.models.auths import (
@ -19,6 +21,7 @@ from open_webui.models.auths import (
UserResponse,
)
from beyond_the_loop.models.users import Users
from beyond_the_loop.services.email_service import EmailService
from beyond_the_loop.models.companies import NO_COMPANY
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
@ -550,6 +553,19 @@ async def signout(request: Request, response: Response):
############################
def generate_secure_password(length=12):
"""Generate a secure random password."""
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
while True:
password = ''.join(secrets.choice(alphabet) for _ in range(length))
# Check if password has at least one of each: uppercase, lowercase, digit, special char
if (any(c.isupper() for c in password)
and any(c.islower() for c in password)
and any(c.isdigit() for c in password)
and any(c in "!@#$%^&*" for c in password)):
return password
@router.post("/add", response_model=SigninResponse)
async def add_user(form_data: AddUserForm, admin_user: Users = Depends(get_admin_user)):
if not validate_email_format(form_data.email.lower()):
@ -561,7 +577,10 @@ async def add_user(form_data: AddUserForm, admin_user: Users = Depends(get_admin
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
try:
hashed = get_password_hash(form_data.password)
# Generate a secure random password
password = generate_secure_password()
hashed = get_password_hash(password)
new_user = Auths.insert_new_auth(
form_data.email.lower(),
hashed,
@ -572,6 +591,14 @@ async def add_user(form_data: AddUserForm, admin_user: Users = Depends(get_admin
)
if new_user:
# Send welcome email with the generated password
email_service = EmailService()
email_service.send_welcome_mail(
to_email=form_data.email.lower(),
username=form_data.name,
password=password
)
token = create_token(data={"id": new_user.id})
return {
"token": token,

View File

@ -19,7 +19,9 @@ from starlette.background import BackgroundTask
from beyond_the_loop.models.models import Models
from beyond_the_loop.models.model_message_credit_costs import ModelMessageCreditCosts
from beyond_the_loop.models.companies import Companies
from beyond_the_loop.models.companies import EIGHTY_PERCENT_CREDIT_LIMIT
from beyond_the_loop.models.completions import Completions
from beyond_the_loop.services.email_service import EmailService
from open_webui.config import (
CACHE_DIR,
@ -565,13 +567,24 @@ async def generate_chat_completion(
# Subtract credits from the company's balance (if possible)
model_message_credit_cost = ModelMessageCreditCosts.get_cost_by_model(model_id)
# Get current credit balance once
current_balance = Companies.get_credit_balance(user.company_id)
# Check if company has sufficient credits
if not Companies.has_sufficient_credits(user.company_id, model_message_credit_cost):
if current_balance < model_message_credit_cost:
email_service = EmailService()
email_service.send_budget_mail_100(to_email=user.email, recipient_name=user.name)
raise HTTPException(
status_code=402, # 402 Payment Required
detail=f"Insufficient credits. This operation requires {model_message_credit_cost} credits.",
)
# Check 80% threshold
if current_balance - model_message_credit_cost < EIGHTY_PERCENT_CREDIT_LIMIT: # If balance is less than 125% of required (which means we're below 80%)
email_service = EmailService()
email_service.send_budget_mail_80(to_email=user.email, recipient_name=user.name)
# Subtract credits from balance
Companies.subtract_credit_balance(user.company_id, model_message_credit_cost)

View File

@ -0,0 +1,117 @@
from typing import Dict, Optional, List
import sib_api_v3_sdk
from sib_api_v3_sdk.rest import ApiException
from pydantic import EmailStr
import os
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
class EmailService:
def __init__(self):
self.configuration = sib_api_v3_sdk.Configuration()
self.configuration.api_key['api-key'] = os.getenv('BREVO_API_KEY')
self.api_instance = sib_api_v3_sdk.TransactionalEmailsApi(sib_api_v3_sdk.ApiClient(self.configuration))
# Initialize Jinja2 environment
template_dir = Path(__file__).parent.parent / 'templates' / 'email'
self.jinja_env = Environment(loader=FileSystemLoader(template_dir))
def send_welcome_mail(self, to_email: EmailStr, username: str, password: str) -> bool:
"""Send a welcome email to newly registered users."""
try:
subject = "Willkommen bei Bchat!"
sender = {"name": "Bchat", "email": os.getenv('SENDER_EMAIL', 'noreply@beyondtheloop.ai')}
to = [{"email": to_email, "name": username}]
# Load and render the template
template = self.jinja_env.get_template('welcome.html')
html_content = template.render(
name=username,
password=password
)
send_smtp_email = sib_api_v3_sdk.SendSmtpEmail(
to=to,
html_content=html_content,
sender=sender,
subject=subject
)
self.api_instance.send_transac_email(send_smtp_email)
return True
except ApiException as e:
print(f"Exception when sending registration email: {e}")
return False
def send_custom_email(self, to_email: EmailStr, subject: str, html_content: str,
recipient_name: Optional[str] = None) -> bool:
"""Send a custom email with specified content."""
try:
sender = {"name": "Beyond The Loop", "email": os.getenv('SENDER_EMAIL', 'noreply@beyondtheloop.com')}
to = [{"email": to_email, "name": recipient_name or to_email}]
send_smtp_email = sib_api_v3_sdk.SendSmtpEmail(
to=to,
html_content=html_content,
sender=sender,
subject=subject
)
self.api_instance.send_transac_email(send_smtp_email)
return True
except ApiException as e:
print(f"Exception when sending custom email: {e}")
return False
def send_budget_mail_80(self, to_email: EmailStr, recipient_name: Optional[str] = None) -> bool:
"""Send a warning email when budget reaches 80% of limit."""
try:
subject = "Abrechnungslimit fast erreicht"
sender = {"name": "Beyond The Loop", "email": os.getenv('SENDER_EMAIL', 'noreply@beyondtheloop.ai')}
to = [{"email": to_email, "name": recipient_name or to_email}]
# Load and render the template
template = self.jinja_env.get_template('budget-mail-80.html')
html_content = template.render()
send_smtp_email = sib_api_v3_sdk.SendSmtpEmail(
to=to,
html_content=html_content,
sender=sender,
subject=subject
)
self.api_instance.send_transac_email(send_smtp_email)
return True
except ApiException as e:
print(f"Exception when sending budget warning (80%) email: {e}")
return False
def send_budget_mail_100(self, to_email: EmailStr, recipient_name: Optional[str] = None) -> bool:
"""Send a critical warning email when budget reaches 100% of limit."""
try:
subject = "Achtung: Abrechnungslimit erreicht!"
sender = {"name": "Beyond The Loop", "email": os.getenv('SENDER_EMAIL', 'noreply@beyondtheloop.ai')}
to = [{"email": to_email, "name": recipient_name or to_email}]
# Load and render the template
template = self.jinja_env.get_template('budget-mail-100.html')
html_content = template.render()
send_smtp_email = sib_api_v3_sdk.SendSmtpEmail(
to=to,
html_content=html_content,
sender=sender,
subject=subject
)
self.api_instance.send_transac_email(send_smtp_email)
return True
except ApiException as e:
print(f"Exception when sending budget warning (100%) email: {e}")
return False

View File

@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Bchat{% endblock %}</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
width: 100%;
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
padding-bottom: 20px;
border-bottom: 1px solid #dddddd;
}
.header h1 {
margin: 0;
color: #333333;
}
.content {
padding: 20px 0;
}
.content p {
color: #666666;
line-height: 1.5;
}
.button {
display: block;
width: 200px;
margin: 20px auto;
padding: 10px 0;
text-align: center;
background-color: #007FFF;
color: #ffffff;
text-decoration: none;
border-radius: 5px;
font-size: 16px;
}
.footer {
text-align: center;
padding-top: 20px;
border-top: 1px solid #dddddd;
color: #999999;
font-size: 12px;
}
.footer a {
color: #007BFF;
text-decoration: none;
}
{% block additional_styles %}{% endblock %}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>{% block header %}{% endblock %}</h1>
</div>
<div class="content">
{% block content %}{% endblock %}
</div>
<div class="footer">
<p>Diese E-Mail wurde automatisch generiert. Bitte antworte nicht direkt darauf. Bei Fragen wenden dich an unseren <a href="mailto:support@beyondtheloop.ai">Support</a>.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Achtung: Abrechnungslimit erreicht!{% endblock %}
{% block header %}Achtung: Abrechnungslimit erreicht!{% endblock %}
{% block content %}
<p>Hi,</p>
<p>das festgelegte Abrechnungslimit für deinen Workspace wurde erreicht. Um Einschränkungen zu vermeiden, ist jetzt dringend deine Aktion erforderlich.</p>
<p>Ohne sofortige Erhöhung des Limits kann dein Team nicht mehr auf BChat zugreifen. Erhöhe das Limit einfach in deinen Unternehmenseinstellungen!</p>
<a href="https://bchat.example.com/billing" class="button">Jetzt Limit erhöhen</a>
<p>Falls der Button nicht funktioniert, kopiere bitte diese URL und füge sie in deinen Browser ein:</p>
<p><a href="https://bchat.example.com/billing">https://bchat.example.com/billing</a></p>
<p>Zögere nicht, uns bei Fragen zu kontaktieren.</p>
<p>Dein Beyond the Loop Team</p>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Abrechnungslimit fast erreicht{% endblock %}
{% block header %}Dein Abrechnungslimit ist fast erreicht!{% endblock %}
{% block content %}
<p>Hallo,</p>
<p>dein Workspace hat gerade 80% des festgelegten Abrechnungslimits erreicht. Keine Sorge, alles läuft noch - aber es ist Zeit, einen Blick in deine Unternehmens-Dashboard zu werfen.</p>
<p>Schraube das Limit rechtzeitig hoch, um sicherzustellen, dass jeder die Platform ohne Einschränkungen nutzen kann.</p>
<a class="button" href="https://bchat.example.com/billing">Abrechnungsdetails überprüfen</a>
<p>Falls du Fragen hast oder Hilfe brauchst - wir sind für dich da!</p>
<p>Danke, dass du bChat nutzt!</p>
<p>Dein Beyond the Loop Team</p>
{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Account Aktivierung{% endblock %}
{% block additional_styles %}
.password {
font-size: 24px;
color: #333333;
text-align: center;
margin: 20px 0;
padding: 15px;
background-color: #f8f9fa;
border-radius: 5px;
border: 1px solid #dee2e6;
}
{% endblock %}
{% block header %}Willkommen bei Bchat!{% endblock %}
{% block content %}
<p>Hallo {{ name }},</p>
<p>Du wurdest zu bChat eingeladen! Um loszulegen, logge dich mit deiner Email-Adresse und folgendem Passwort ein:</p>
<div class="password">{{ password }}</div>
<p>Viel Spaß beim Entdecken von bChat!</p>
{% endblock %}
{% block footer %}
<p>Diese E-Mail wurde automatisch generiert. Bitte antworte nicht direkt darauf. Bei Fragen wenden dich an unseren <a href="mailto:support@beyondtheloop.ai">Support</a>.</p>
{% endblock %}

View File

@ -90,8 +90,11 @@ class SignupForm(BaseModel):
profile_image_url: Optional[str] = "/user.png"
class AddUserForm(SignupForm):
role: Optional[str] = "pending",
class AddUserForm(BaseModel):
name: str
email: str
profile_image_url: Optional[str] = "/user.png"
role: Optional[str] = "pending"
class AuthsTable:

View File

@ -1 +1 @@
Name,Email,Password,Role
Name,Email,Role

1 Name Email Password Role

View File

@ -106,3 +106,6 @@ google-cloud-storage==2.19.0
## LDAP
ldap3==2.9.1
jinja2==3.1.3
sib-api-v3-sdk==7.6.0

View File

@ -353,7 +353,6 @@ export const addUser = async (
token: string,
name: string,
email: string,
password: string,
role: string = 'pending'
) => {
let error = null;
@ -367,7 +366,6 @@ export const addUser = async (
body: JSON.stringify({
name: name,
email: email,
password: password,
role: role
})
})

View File

@ -20,7 +20,6 @@
let _user = {
name: '',
email: '',
password: '',
role: 'user'
};
@ -28,7 +27,6 @@
_user = {
name: '',
email: '',
password: '',
role: 'user'
};
}
@ -42,15 +40,11 @@
if (tab === '') {
loading = true;
const res = await addUser(
localStorage.token,
_user.name,
_user.email,
_user.password,
_user.role
).catch((error) => {
toast.error(`${error}`);
});
const res = await addUser(localStorage.token, _user.name, _user.email, _user.role).catch(
(error) => {
toast.error(`${error}`);
}
);
if (res) {
stopLoading();
@ -75,15 +69,14 @@
if (idx > 0) {
if (
columns.length === 4 &&
['admin', 'user', 'pending'].includes(columns[3].toLowerCase())
columns.length === 3 &&
['admin', 'user', 'pending'].includes(columns[2].toLowerCase())
) {
const res = await addUser(
localStorage.token,
columns[0],
columns[1],
columns[2],
columns[3].toLowerCase()
columns[0], // name
columns[1], // email
columns[2].toLowerCase() // role
).catch((error) => {
toast.error(`Row ${idx + 1}: ${error}`);
return null;
@ -201,7 +194,7 @@
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
type="text"
bind:value={_user.name}
placeholder={$i18n.t('Enter Your Full Name')}
placeholder={$i18n.t('Enter Full Name')}
autocomplete="off"
required
/>
@ -218,25 +211,11 @@
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
type="email"
bind:value={_user.email}
placeholder={$i18n.t('Enter Your Email')}
placeholder={$i18n.t('Enter Email')}
required
/>
</div>
</div>
<div class="flex flex-col w-full mt-1">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Password')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
type="password"
bind:value={_user.password}
placeholder={$i18n.t('Enter Your Password')}
autocomplete="off"
/>
</div>
</div>
{:else if tab === 'import'}
<div>
<div class="mb-3 w-full">
@ -265,7 +244,7 @@
<div class=" text-xs text-gray-500">
{$i18n.t(
'Ensure your CSV file includes 4 columns in this order: Name, Email, Password, Role.'
'Ensure your CSV file includes 4 columns in this order: Name, Email, Role.'
)}
<a
class="underline dark:text-gray-200"