feat: Add OAuth email integration and production deployment config
- Add OAuthCredential model for storing Google/Microsoft OAuth tokens - Add email provider auto-detection endpoint (Gmail, Outlook, Yahoo, etc.) - Add EmailConfigWizard frontend component with step-by-step setup - Add OAuth flow endpoints for Google and Microsoft XOAUTH2 - Update production settings to make AWS, Sentry, Mailgun optional - Update Traefik config for wildcard subdomain routing - Add logo resize utility script 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
133
frontend/PLAN_EMAIL_WIZARD.md
Normal file
133
frontend/PLAN_EMAIL_WIZARD.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Email Configuration Wizard Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Create a step-by-step wizard for configuring email settings with:
|
||||
1. Auto-detection of IMAP/SMTP settings from email address
|
||||
2. OAuth support for Gmail accounts
|
||||
3. Manual configuration fallback
|
||||
|
||||
## Wizard Steps
|
||||
|
||||
### Step 1: Email Address Entry
|
||||
- User enters their support email address (e.g., support@company.com)
|
||||
- System extracts domain and attempts auto-detection
|
||||
- Shows detected provider (Gmail, Outlook, Yahoo, custom domain)
|
||||
|
||||
### Step 2: Authentication Method Selection
|
||||
- **For Gmail**: Show "Connect with Google" OAuth button
|
||||
- **For Outlook/Microsoft 365**: Show "Connect with Microsoft" OAuth button
|
||||
- **For others**: Show manual configuration option
|
||||
|
||||
### Step 3a: OAuth Flow (Gmail/Microsoft)
|
||||
- Redirect to OAuth provider
|
||||
- Request mail scopes (IMAP, SMTP access)
|
||||
- Store OAuth tokens for authentication
|
||||
- Auto-configure IMAP/SMTP settings
|
||||
|
||||
### Step 3b: Manual Configuration
|
||||
- Pre-fill detected IMAP/SMTP settings
|
||||
- Allow user to modify if needed
|
||||
- Password/app-specific password entry
|
||||
|
||||
### Step 4: Test & Verify
|
||||
- Test IMAP connection
|
||||
- Test SMTP connection
|
||||
- Show success or troubleshooting steps
|
||||
|
||||
### Step 5: Additional Settings
|
||||
- From name configuration
|
||||
- Check interval
|
||||
- Delete after processing toggle
|
||||
|
||||
## Email Provider Database
|
||||
|
||||
Common providers with auto-detection:
|
||||
|
||||
| Domain | Provider | IMAP Host | IMAP Port | SMTP Host | SMTP Port | OAuth |
|
||||
|--------|----------|-----------|-----------|-----------|-----------|-------|
|
||||
| gmail.com | Gmail | imap.gmail.com | 993 | smtp.gmail.com | 587 | Yes |
|
||||
| googlemail.com | Gmail | imap.gmail.com | 993 | smtp.gmail.com | 587 | Yes |
|
||||
| outlook.com | Microsoft | outlook.office365.com | 993 | smtp.office365.com | 587 | Yes |
|
||||
| hotmail.com | Microsoft | outlook.office365.com | 993 | smtp.office365.com | 587 | Yes |
|
||||
| live.com | Microsoft | outlook.office365.com | 993 | smtp.office365.com | 587 | Yes |
|
||||
| yahoo.com | Yahoo | imap.mail.yahoo.com | 993 | smtp.mail.yahoo.com | 587 | No |
|
||||
| icloud.com | Apple | imap.mail.me.com | 993 | smtp.mail.me.com | 587 | No |
|
||||
| aol.com | AOL | imap.aol.com | 993 | smtp.aol.com | 587 | No |
|
||||
|
||||
For custom domains: Use MX record lookup to detect if hosted by Gmail/Microsoft
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### New API Endpoints
|
||||
|
||||
1. `POST /api/tickets/email-settings/detect/`
|
||||
- Input: `{ email: "support@company.com" }`
|
||||
- Output: Detected provider info and suggested settings
|
||||
|
||||
2. `POST /api/tickets/email-settings/oauth/google/`
|
||||
- Initiate Google OAuth flow for Gmail access
|
||||
|
||||
3. `POST /api/tickets/email-settings/oauth/google/callback/`
|
||||
- Handle OAuth callback, store tokens
|
||||
|
||||
4. `POST /api/tickets/email-settings/oauth/microsoft/`
|
||||
- Initiate Microsoft OAuth flow
|
||||
|
||||
5. `POST /api/tickets/email-settings/oauth/microsoft/callback/`
|
||||
- Handle Microsoft OAuth callback
|
||||
|
||||
### Model Changes
|
||||
|
||||
Add to TicketEmailSettings:
|
||||
- `oauth_provider`: CharField (google, microsoft, null)
|
||||
- `oauth_access_token`: TextField (encrypted)
|
||||
- `oauth_refresh_token`: TextField (encrypted)
|
||||
- `oauth_token_expiry`: DateTimeField
|
||||
- `use_oauth`: BooleanField
|
||||
|
||||
### OAuth Scopes Required
|
||||
|
||||
**Google Gmail API:**
|
||||
- `https://mail.google.com/` (full mail access for IMAP/SMTP)
|
||||
- OR use Gmail API directly instead of IMAP
|
||||
|
||||
**Microsoft Graph API:**
|
||||
- `https://outlook.office.com/IMAP.AccessAsUser.All`
|
||||
- `https://outlook.office.com/SMTP.Send`
|
||||
|
||||
## Frontend Components
|
||||
|
||||
### EmailConfigWizard.tsx
|
||||
Main wizard component with step navigation
|
||||
|
||||
### Steps:
|
||||
1. EmailAddressStep - Email input with domain detection
|
||||
2. AuthMethodStep - OAuth vs manual selection
|
||||
3. OAuthConnectStep - OAuth flow handling
|
||||
4. ManualConfigStep - IMAP/SMTP form fields
|
||||
5. TestConnectionStep - Connection testing
|
||||
6. FinalSettingsStep - Additional options
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Backend: Email provider detection endpoint
|
||||
2. Frontend: Wizard UI with steps
|
||||
3. Backend: Google OAuth integration
|
||||
4. Frontend: OAuth flow handling
|
||||
5. Backend: Microsoft OAuth integration
|
||||
6. Testing and refinement
|
||||
|
||||
## Questions to Resolve
|
||||
|
||||
1. Should we use IMAP/SMTP with OAuth tokens, or switch to Gmail/Graph API?
|
||||
- IMAP/SMTP with XOAUTH2 is simpler, works with existing code
|
||||
- API approach is more modern but requires rewriting email fetcher
|
||||
|
||||
2. Store OAuth tokens in TicketEmailSettings or separate model?
|
||||
- Same model is simpler
|
||||
- Separate model allows multiple OAuth connections
|
||||
|
||||
3. How to handle token refresh?
|
||||
- Background task to refresh before expiry
|
||||
- Refresh on-demand when making email requests
|
||||
@@ -78,6 +78,25 @@ export interface FetchNowResult {
|
||||
processed: number;
|
||||
}
|
||||
|
||||
export interface EmailProviderDetectResult {
|
||||
success: boolean;
|
||||
email: string;
|
||||
domain: string;
|
||||
detected: boolean;
|
||||
detected_via?: 'domain_lookup' | 'mx_record';
|
||||
provider: 'google' | 'microsoft' | 'yahoo' | 'apple' | 'aol' | 'zoho' | 'protonmail' | 'unknown';
|
||||
display_name: string;
|
||||
imap_host?: string;
|
||||
imap_port?: number;
|
||||
smtp_host?: string;
|
||||
smtp_port?: number;
|
||||
oauth_supported: boolean;
|
||||
message?: string;
|
||||
notes?: string;
|
||||
suggested_imap_port?: number;
|
||||
suggested_smtp_port?: number;
|
||||
}
|
||||
|
||||
export interface IncomingTicketEmail {
|
||||
id: number;
|
||||
message_id: string;
|
||||
@@ -167,3 +186,77 @@ export const reprocessIncomingEmail = async (id: number): Promise<{
|
||||
const response = await apiClient.post(`/api/tickets/incoming-emails/${id}/reprocess/`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect email provider from email address
|
||||
* Auto-detects Gmail, Outlook, Yahoo, iCloud, etc. from domain
|
||||
* Also checks MX records for custom domains using Google Workspace or Microsoft 365
|
||||
*/
|
||||
export const detectEmailProvider = async (email: string): Promise<EmailProviderDetectResult> => {
|
||||
const response = await apiClient.post('/api/tickets/email-settings/detect/', { email });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// OAuth types and functions
|
||||
export interface OAuthStatusResult {
|
||||
google: { configured: boolean };
|
||||
microsoft: { configured: boolean };
|
||||
}
|
||||
|
||||
export interface OAuthInitiateResult {
|
||||
success: boolean;
|
||||
authorization_url?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface OAuthCredential {
|
||||
id: number;
|
||||
provider: 'google' | 'microsoft';
|
||||
email: string;
|
||||
purpose: string;
|
||||
is_valid: boolean;
|
||||
is_expired: boolean;
|
||||
last_used_at: string | null;
|
||||
last_error: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth configuration status
|
||||
*/
|
||||
export const getOAuthStatus = async (): Promise<OAuthStatusResult> => {
|
||||
const response = await apiClient.get('/api/oauth/status/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initiate Google OAuth flow
|
||||
*/
|
||||
export const initiateGoogleOAuth = async (purpose: string = 'email'): Promise<OAuthInitiateResult> => {
|
||||
const response = await apiClient.post('/api/oauth/google/initiate/', { purpose });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initiate Microsoft OAuth flow
|
||||
*/
|
||||
export const initiateMicrosoftOAuth = async (purpose: string = 'email'): Promise<OAuthInitiateResult> => {
|
||||
const response = await apiClient.post('/api/oauth/microsoft/initiate/', { purpose });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* List OAuth credentials
|
||||
*/
|
||||
export const getOAuthCredentials = async (): Promise<OAuthCredential[]> => {
|
||||
const response = await apiClient.get('/api/oauth/credentials/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete OAuth credential
|
||||
*/
|
||||
export const deleteOAuthCredential = async (id: number): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.delete(`/api/oauth/credentials/${id}/`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
1004
frontend/src/components/EmailConfigWizard.tsx
Normal file
1004
frontend/src/components/EmailConfigWizard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,9 +11,19 @@ import {
|
||||
fetchEmailsNow,
|
||||
getIncomingEmails,
|
||||
reprocessIncomingEmail,
|
||||
detectEmailProvider,
|
||||
getOAuthStatus,
|
||||
initiateGoogleOAuth,
|
||||
initiateMicrosoftOAuth,
|
||||
getOAuthCredentials,
|
||||
deleteOAuthCredential,
|
||||
TicketEmailSettings,
|
||||
TicketEmailSettingsUpdate,
|
||||
IncomingTicketEmail,
|
||||
EmailProviderDetectResult,
|
||||
OAuthStatusResult,
|
||||
OAuthInitiateResult,
|
||||
OAuthCredential,
|
||||
} from '../api/ticketEmailSettings';
|
||||
|
||||
const QUERY_KEY = 'ticketEmailSettings';
|
||||
@@ -103,4 +113,77 @@ export const useReprocessIncomingEmail = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export type { TicketEmailSettings, TicketEmailSettingsUpdate, IncomingTicketEmail };
|
||||
/**
|
||||
* Hook to detect email provider from email address
|
||||
*/
|
||||
export const useDetectEmailProvider = () => {
|
||||
return useMutation({
|
||||
mutationFn: (email: string) => detectEmailProvider(email),
|
||||
});
|
||||
};
|
||||
|
||||
// OAuth Hooks
|
||||
const OAUTH_STATUS_KEY = 'oauthStatus';
|
||||
const OAUTH_CREDENTIALS_KEY = 'oauthCredentials';
|
||||
|
||||
/**
|
||||
* Hook to get OAuth configuration status
|
||||
*/
|
||||
export const useOAuthStatus = () => {
|
||||
return useQuery<OAuthStatusResult>({
|
||||
queryKey: [OAUTH_STATUS_KEY],
|
||||
queryFn: getOAuthStatus,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to initiate Google OAuth flow
|
||||
*/
|
||||
export const useInitiateGoogleOAuth = () => {
|
||||
return useMutation({
|
||||
mutationFn: (purpose: string = 'email') => initiateGoogleOAuth(purpose),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to initiate Microsoft OAuth flow
|
||||
*/
|
||||
export const useInitiateMicrosoftOAuth = () => {
|
||||
return useMutation({
|
||||
mutationFn: (purpose: string = 'email') => initiateMicrosoftOAuth(purpose),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to list OAuth credentials
|
||||
*/
|
||||
export const useOAuthCredentials = () => {
|
||||
return useQuery<OAuthCredential[]>({
|
||||
queryKey: [OAUTH_CREDENTIALS_KEY],
|
||||
queryFn: getOAuthCredentials,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to delete OAuth credential
|
||||
*/
|
||||
export const useDeleteOAuthCredential = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteOAuthCredential(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [OAUTH_CREDENTIALS_KEY] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export type {
|
||||
TicketEmailSettings,
|
||||
TicketEmailSettingsUpdate,
|
||||
IncomingTicketEmail,
|
||||
EmailProviderDetectResult,
|
||||
OAuthStatusResult,
|
||||
OAuthInitiateResult,
|
||||
OAuthCredential,
|
||||
};
|
||||
|
||||
@@ -55,7 +55,8 @@ import {
|
||||
useTestSmtpConnection,
|
||||
useFetchEmailsNow,
|
||||
} from '../../hooks/useTicketEmailSettings';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Send, Wand2 } from 'lucide-react';
|
||||
import EmailConfigWizard from '../../components/EmailConfigWizard';
|
||||
|
||||
type TabType = 'general' | 'stripe' | 'tiers' | 'oauth';
|
||||
|
||||
@@ -119,12 +120,14 @@ const PlatformSettings: React.FC = () => {
|
||||
|
||||
const GeneralSettingsTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: emailSettings, isLoading, error } = useTicketEmailSettings();
|
||||
const { data: emailSettings, isLoading, error, refetch } = useTicketEmailSettings();
|
||||
const updateMutation = useUpdateTicketEmailSettings();
|
||||
const testImapMutation = useTestImapConnection();
|
||||
const testSmtpMutation = useTestSmtpConnection();
|
||||
const fetchNowMutation = useFetchEmailsNow();
|
||||
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
// IMAP settings
|
||||
imap_host: '',
|
||||
@@ -228,14 +231,39 @@ const GeneralSettingsTab: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Show wizard if requested
|
||||
if (showWizard) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<EmailConfigWizard
|
||||
onComplete={() => {
|
||||
setShowWizard(false);
|
||||
refetch();
|
||||
}}
|
||||
onCancel={() => setShowWizard(false)}
|
||||
initialEmail={emailSettings?.imap_username || ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Email Processing Status */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" />
|
||||
{t('platform.settings.emailProcessing', 'Support Email Processing')}
|
||||
</h2>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" />
|
||||
{t('platform.settings.emailProcessing', 'Support Email Processing')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowWizard(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
|
||||
>
|
||||
<Wand2 className="w-4 h-4" />
|
||||
{t('platform.settings.setupWizard', 'Setup Wizard')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
|
||||
65
resize_logo.py
Normal file
65
resize_logo.py
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to resize the SmoothSchedule logo to 120x120 pixels.
|
||||
Maintains aspect ratio and centers on a transparent canvas.
|
||||
Usage: python resize_logo.py
|
||||
"""
|
||||
|
||||
from PIL import Image
|
||||
from pathlib import Path
|
||||
|
||||
# Paths
|
||||
PROJECT_ROOT = Path(__file__).parent
|
||||
SOURCE_LOGO = PROJECT_ROOT / "frontend" / "src" / "assets" / "smooth_schedule_icon.png"
|
||||
OUTPUT_LOGO = PROJECT_ROOT / "smooth_schedule_logo_120x120.png"
|
||||
|
||||
def resize_logo(source: Path, output: Path, size: tuple = (120, 120)):
|
||||
"""Resize logo to fit within specified dimensions, maintaining aspect ratio."""
|
||||
if not source.exists():
|
||||
print(f"Error: Source logo not found at {source}")
|
||||
return False
|
||||
|
||||
try:
|
||||
with Image.open(source) as img:
|
||||
# Convert to RGBA if needed (preserves transparency)
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
|
||||
original_size = img.size
|
||||
|
||||
# Calculate scaling to fit within the target size while maintaining aspect ratio
|
||||
width_ratio = size[0] / img.width
|
||||
height_ratio = size[1] / img.height
|
||||
scale = min(width_ratio, height_ratio)
|
||||
|
||||
new_width = int(img.width * scale)
|
||||
new_height = int(img.height * scale)
|
||||
|
||||
# Resize maintaining aspect ratio
|
||||
resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Create a transparent canvas of the target size
|
||||
canvas = Image.new('RGBA', size, (0, 0, 0, 0))
|
||||
|
||||
# Center the resized image on the canvas
|
||||
x_offset = (size[0] - new_width) // 2
|
||||
y_offset = (size[1] - new_height) // 2
|
||||
canvas.paste(resized, (x_offset, y_offset))
|
||||
|
||||
# Save the final image
|
||||
canvas.save(output, 'PNG', optimize=True)
|
||||
|
||||
print(f"Successfully resized logo:")
|
||||
print(f" Source: {source}")
|
||||
print(f" Output: {output}")
|
||||
print(f" Original size: {original_size}")
|
||||
print(f" Scaled size: {new_width}x{new_height}")
|
||||
print(f" Canvas size: {size}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error resizing logo: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
resize_logo(SOURCE_LOGO, OUTPUT_LOGO)
|
||||
BIN
smooth_schedule_logo_120x120.png
Normal file
BIN
smooth_schedule_logo_120x120.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
@@ -24,12 +24,13 @@ certificatesResolvers:
|
||||
acme:
|
||||
email: 'admin@smoothschedule.com'
|
||||
storage: /etc/traefik/acme/acme.json
|
||||
# https://doc.traefik.io/traefik/https/acme/#httpchallenge
|
||||
# HTTP challenge for individual domains
|
||||
httpChallenge:
|
||||
entryPoint: web
|
||||
|
||||
http:
|
||||
routers:
|
||||
# Main domain and www
|
||||
web-secure-router:
|
||||
rule: 'Host(`smoothschedule.com`) || Host(`www.smoothschedule.com`)'
|
||||
entryPoints:
|
||||
@@ -38,7 +39,40 @@ http:
|
||||
- csrf
|
||||
service: django
|
||||
tls:
|
||||
# https://doc.traefik.io/traefik/routing/routers/#certresolver
|
||||
certResolver: letsencrypt
|
||||
|
||||
# Platform subdomain (admin dashboard)
|
||||
platform-router:
|
||||
rule: 'Host(`platform.smoothschedule.com`)'
|
||||
entryPoints:
|
||||
- web-secure
|
||||
middlewares:
|
||||
- csrf
|
||||
service: django
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
# API subdomain
|
||||
api-router:
|
||||
rule: 'Host(`api.smoothschedule.com`)'
|
||||
entryPoints:
|
||||
- web-secure
|
||||
middlewares:
|
||||
- csrf
|
||||
service: django
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
# Wildcard subdomain router for tenant subdomains
|
||||
# Each subdomain gets its own certificate via HTTP challenge
|
||||
subdomain-router:
|
||||
rule: 'HostRegexp(`{subdomain:[a-z0-9-]+}.smoothschedule.com`)'
|
||||
entryPoints:
|
||||
- web-secure
|
||||
middlewares:
|
||||
- csrf
|
||||
service: django
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
flower-secure-router:
|
||||
@@ -47,7 +81,6 @@ http:
|
||||
- flower
|
||||
service: flower
|
||||
tls:
|
||||
# https://doc.traefik.io/traefik/master/routing/routers/#certresolver
|
||||
certResolver: letsencrypt
|
||||
|
||||
middlewares:
|
||||
|
||||
@@ -353,3 +353,19 @@ STRIPE_LIVE_MODE = env.bool("STRIPE_LIVE_MODE", default=False)
|
||||
DJSTRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", default="")
|
||||
DJSTRIPE_USE_NATIVE_JSONFIELD = True
|
||||
DJSTRIPE_FOREIGN_KEY_TO_FIELD = "id"
|
||||
|
||||
# OAuth for Email Integration (IMAP/SMTP with XOAUTH2)
|
||||
# ------------------------------------------------------------------------------
|
||||
# Google OAuth (Gmail, Google Workspace)
|
||||
# Create credentials at: https://console.cloud.google.com/apis/credentials
|
||||
GOOGLE_OAUTH_CLIENT_ID = env("GOOGLE_OAUTH_CLIENT_ID", default="")
|
||||
GOOGLE_OAUTH_CLIENT_SECRET = env("GOOGLE_OAUTH_CLIENT_SECRET", default="")
|
||||
|
||||
# Microsoft OAuth (Outlook, Office 365)
|
||||
# Create app at: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade
|
||||
MICROSOFT_OAUTH_CLIENT_ID = env("MICROSOFT_OAUTH_CLIENT_ID", default="")
|
||||
MICROSOFT_OAUTH_CLIENT_SECRET = env("MICROSOFT_OAUTH_CLIENT_SECRET", default="")
|
||||
MICROSOFT_OAUTH_TENANT_ID = env("MICROSOFT_OAUTH_TENANT_ID", default="common")
|
||||
|
||||
# Frontend URL (for OAuth callback redirects)
|
||||
FRONTEND_URL = env("FRONTEND_URL", default="http://platform.lvh.me:5173")
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
# ruff: noqa: E501
|
||||
import logging
|
||||
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
|
||||
from .base import * # noqa: F403
|
||||
from .base import DATABASES
|
||||
from .base import INSTALLED_APPS
|
||||
@@ -72,45 +66,49 @@ SECURE_CONTENT_TYPE_NOSNIFF = env.bool(
|
||||
)
|
||||
|
||||
|
||||
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
|
||||
AWS_ACCESS_KEY_ID = env("DJANGO_AWS_ACCESS_KEY_ID")
|
||||
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
|
||||
AWS_SECRET_ACCESS_KEY = env("DJANGO_AWS_SECRET_ACCESS_KEY")
|
||||
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
|
||||
AWS_STORAGE_BUCKET_NAME = env("DJANGO_AWS_STORAGE_BUCKET_NAME")
|
||||
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
|
||||
AWS_QUERYSTRING_AUTH = False
|
||||
# DO NOT change these unless you know what you're doing.
|
||||
_AWS_EXPIRY = 60 * 60 * 24 * 7
|
||||
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
|
||||
AWS_S3_OBJECT_PARAMETERS = {
|
||||
"CacheControl": f"max-age={_AWS_EXPIRY}, s-maxage={_AWS_EXPIRY}, must-revalidate",
|
||||
}
|
||||
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
|
||||
AWS_S3_MAX_MEMORY_SIZE = env.int(
|
||||
"DJANGO_AWS_S3_MAX_MEMORY_SIZE",
|
||||
default=100_000_000, # 100MB
|
||||
)
|
||||
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
|
||||
AWS_S3_REGION_NAME = env("DJANGO_AWS_S3_REGION_NAME", default=None)
|
||||
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#cloudfront
|
||||
AWS_S3_CUSTOM_DOMAIN = env("DJANGO_AWS_S3_CUSTOM_DOMAIN", default=None)
|
||||
aws_s3_domain = AWS_S3_CUSTOM_DOMAIN or f"{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com"
|
||||
# STATIC & MEDIA
|
||||
# ------------------------
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "storages.backends.s3.S3Storage",
|
||||
"OPTIONS": {
|
||||
"location": "media",
|
||||
"file_overwrite": False,
|
||||
# AWS S3 storage is optional - only configure if credentials are provided
|
||||
AWS_ACCESS_KEY_ID = env("DJANGO_AWS_ACCESS_KEY_ID", default="")
|
||||
AWS_SECRET_ACCESS_KEY = env("DJANGO_AWS_SECRET_ACCESS_KEY", default="")
|
||||
AWS_STORAGE_BUCKET_NAME = env("DJANGO_AWS_STORAGE_BUCKET_NAME", default="")
|
||||
|
||||
if AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY and AWS_STORAGE_BUCKET_NAME:
|
||||
# Use S3 for media storage
|
||||
AWS_QUERYSTRING_AUTH = False
|
||||
_AWS_EXPIRY = 60 * 60 * 24 * 7
|
||||
AWS_S3_OBJECT_PARAMETERS = {
|
||||
"CacheControl": f"max-age={_AWS_EXPIRY}, s-maxage={_AWS_EXPIRY}, must-revalidate",
|
||||
}
|
||||
AWS_S3_MAX_MEMORY_SIZE = env.int("DJANGO_AWS_S3_MAX_MEMORY_SIZE", default=100_000_000)
|
||||
AWS_S3_REGION_NAME = env("DJANGO_AWS_S3_REGION_NAME", default=None)
|
||||
AWS_S3_CUSTOM_DOMAIN = env("DJANGO_AWS_S3_CUSTOM_DOMAIN", default=None)
|
||||
aws_s3_domain = AWS_S3_CUSTOM_DOMAIN or f"{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com"
|
||||
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "storages.backends.s3.S3Storage",
|
||||
"OPTIONS": {
|
||||
"location": "media",
|
||||
"file_overwrite": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
MEDIA_URL = f"https://{aws_s3_domain}/media/"
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
MEDIA_URL = f"https://{aws_s3_domain}/media/"
|
||||
else:
|
||||
# Use local filesystem storage
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
MEDIA_URL = "/media/"
|
||||
|
||||
# EMAIL
|
||||
# ------------------------------------------------------------------------------
|
||||
@@ -133,19 +131,25 @@ ACCOUNT_EMAIL_SUBJECT_PREFIX = EMAIL_SUBJECT_PREFIX
|
||||
# Django Admin URL regex.
|
||||
ADMIN_URL = env("DJANGO_ADMIN_URL")
|
||||
|
||||
# Anymail
|
||||
# Anymail (optional - falls back to console email if not configured)
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://anymail.readthedocs.io/en/stable/installation/#installing-anymail
|
||||
INSTALLED_APPS += ["anymail"]
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
|
||||
# https://anymail.readthedocs.io/en/stable/installation/#anymail-settings-reference
|
||||
# https://anymail.readthedocs.io/en/stable/esps/mailgun/
|
||||
EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend"
|
||||
ANYMAIL = {
|
||||
"MAILGUN_API_KEY": env("MAILGUN_API_KEY"),
|
||||
"MAILGUN_SENDER_DOMAIN": env("MAILGUN_DOMAIN"),
|
||||
"MAILGUN_API_URL": env("MAILGUN_API_URL", default="https://api.mailgun.net/v3"),
|
||||
}
|
||||
MAILGUN_API_KEY = env("MAILGUN_API_KEY", default="")
|
||||
MAILGUN_DOMAIN = env("MAILGUN_DOMAIN", default="")
|
||||
|
||||
if MAILGUN_API_KEY and MAILGUN_DOMAIN:
|
||||
INSTALLED_APPS += ["anymail"]
|
||||
EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend"
|
||||
ANYMAIL = {
|
||||
"MAILGUN_API_KEY": MAILGUN_API_KEY,
|
||||
"MAILGUN_SENDER_DOMAIN": MAILGUN_DOMAIN,
|
||||
"MAILGUN_API_URL": env("MAILGUN_API_URL", default="https://api.mailgun.net/v3"),
|
||||
}
|
||||
else:
|
||||
# Fall back to SMTP or console email
|
||||
EMAIL_BACKEND = env(
|
||||
"DJANGO_EMAIL_BACKEND",
|
||||
default="django.core.mail.backends.console.EmailBackend"
|
||||
)
|
||||
|
||||
|
||||
# LOGGING
|
||||
@@ -186,27 +190,35 @@ LOGGING = {
|
||||
},
|
||||
}
|
||||
|
||||
# Sentry
|
||||
# Sentry (optional)
|
||||
# ------------------------------------------------------------------------------
|
||||
SENTRY_DSN = env("SENTRY_DSN")
|
||||
SENTRY_LOG_LEVEL = env.int("DJANGO_SENTRY_LOG_LEVEL", logging.INFO)
|
||||
SENTRY_DSN = env("SENTRY_DSN", default="")
|
||||
|
||||
sentry_logging = LoggingIntegration(
|
||||
level=SENTRY_LOG_LEVEL, # Capture info and above as breadcrumbs
|
||||
event_level=logging.ERROR, # Send errors as events
|
||||
)
|
||||
integrations = [
|
||||
sentry_logging,
|
||||
DjangoIntegration(),
|
||||
CeleryIntegration(),
|
||||
RedisIntegration(),
|
||||
]
|
||||
sentry_sdk.init(
|
||||
dsn=SENTRY_DSN,
|
||||
integrations=integrations,
|
||||
environment=env("SENTRY_ENVIRONMENT", default="production"),
|
||||
traces_sample_rate=env.float("SENTRY_TRACES_SAMPLE_RATE", default=0.0),
|
||||
)
|
||||
if SENTRY_DSN:
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
|
||||
SENTRY_LOG_LEVEL = env.int("DJANGO_SENTRY_LOG_LEVEL", logging.INFO)
|
||||
|
||||
sentry_logging = LoggingIntegration(
|
||||
level=SENTRY_LOG_LEVEL,
|
||||
event_level=logging.ERROR,
|
||||
)
|
||||
integrations = [
|
||||
sentry_logging,
|
||||
DjangoIntegration(),
|
||||
CeleryIntegration(),
|
||||
RedisIntegration(),
|
||||
]
|
||||
sentry_sdk.init(
|
||||
dsn=SENTRY_DSN,
|
||||
integrations=integrations,
|
||||
environment=env("SENTRY_ENVIRONMENT", default="production"),
|
||||
traces_sample_rate=env.float("SENTRY_TRACES_SAMPLE_RATE", default=0.0),
|
||||
)
|
||||
|
||||
# django-rest-framework
|
||||
# -------------------------------------------------------------------------------
|
||||
|
||||
@@ -61,6 +61,8 @@ urlpatterns += [
|
||||
path("api/notifications/", include("notifications.urls")),
|
||||
# Platform API
|
||||
path("api/platform/", include("platform_admin.urls", namespace="platform")),
|
||||
# OAuth Email Integration API
|
||||
path("api/oauth/", include("core.oauth_urls", namespace="oauth")),
|
||||
# Auth API
|
||||
path("api/auth-token/", csrf_exempt(obtain_auth_token), name="obtain_auth_token"),
|
||||
path("api/auth/login/", login_view, name="login"),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-30 00:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_add_feature_limits'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OAuthCredential',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('provider', models.CharField(choices=[('google', 'Google'), ('microsoft', 'Microsoft')], help_text='OAuth provider (google, microsoft)', max_length=20)),
|
||||
('purpose', models.CharField(choices=[('email', 'Email Access (IMAP/SMTP)'), ('calendar', 'Calendar Sync'), ('drive', 'File Storage')], help_text='What this credential is used for', max_length=20)),
|
||||
('email', models.EmailField(help_text='Email address associated with this OAuth credential', max_length=254)),
|
||||
('access_token', models.TextField(help_text='OAuth access token')),
|
||||
('refresh_token', models.TextField(blank=True, default='', help_text='OAuth refresh token (for token renewal)')),
|
||||
('token_expiry', models.DateTimeField(blank=True, help_text='When the access token expires', null=True)),
|
||||
('scopes', models.JSONField(blank=True, default=list, help_text='OAuth scopes granted by the user')),
|
||||
('is_valid', models.BooleanField(default=True, help_text='Whether this credential is currently valid')),
|
||||
('last_used_at', models.DateTimeField(blank=True, help_text='When this credential was last used', null=True)),
|
||||
('last_error', models.TextField(blank=True, default='', help_text='Last error message if token refresh failed')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('authorized_by', models.ForeignKey(blank=True, help_text='User who authorized this OAuth connection.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='authorized_oauth_credentials', to=settings.AUTH_USER_MODEL)),
|
||||
('tenant', models.ForeignKey(blank=True, help_text='Tenant this credential belongs to. Null for platform-level credentials.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='service_oauth_credentials', to='core.tenant')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['tenant', 'provider', 'purpose'], name='core_oauthc_tenant__1c2893_idx'), models.Index(fields=['email'], name='core_oauthc_email_dab500_idx')],
|
||||
'unique_together': {('tenant', 'email', 'purpose')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -391,6 +391,169 @@ class PermissionGrant(models.Model):
|
||||
)
|
||||
|
||||
|
||||
class OAuthCredential(models.Model):
|
||||
"""
|
||||
Reusable OAuth credentials for service-level integrations.
|
||||
|
||||
Used for:
|
||||
- Email access (Gmail XOAUTH2, Microsoft XOAUTH2)
|
||||
- Calendar sync (Google Calendar, Outlook Calendar)
|
||||
- Other API integrations requiring OAuth tokens
|
||||
|
||||
Supports on-demand token refresh when tokens expire.
|
||||
"""
|
||||
|
||||
class Provider(models.TextChoices):
|
||||
GOOGLE = 'google', 'Google'
|
||||
MICROSOFT = 'microsoft', 'Microsoft'
|
||||
|
||||
class Purpose(models.TextChoices):
|
||||
EMAIL = 'email', 'Email Access (IMAP/SMTP)'
|
||||
CALENDAR = 'calendar', 'Calendar Sync'
|
||||
DRIVE = 'drive', 'File Storage'
|
||||
|
||||
# Owner - can be platform-level (null tenant) or tenant-specific
|
||||
tenant = models.ForeignKey(
|
||||
'core.Tenant',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='service_oauth_credentials',
|
||||
help_text="Tenant this credential belongs to. Null for platform-level credentials."
|
||||
)
|
||||
|
||||
# Which user authorized this (for audit/revocation)
|
||||
authorized_by = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='authorized_oauth_credentials',
|
||||
help_text="User who authorized this OAuth connection."
|
||||
)
|
||||
|
||||
# OAuth provider and purpose
|
||||
provider = models.CharField(
|
||||
max_length=20,
|
||||
choices=Provider.choices,
|
||||
help_text="OAuth provider (google, microsoft)"
|
||||
)
|
||||
purpose = models.CharField(
|
||||
max_length=20,
|
||||
choices=Purpose.choices,
|
||||
help_text="What this credential is used for"
|
||||
)
|
||||
|
||||
# The email/account this credential is for
|
||||
email = models.EmailField(
|
||||
help_text="Email address associated with this OAuth credential"
|
||||
)
|
||||
|
||||
# OAuth tokens (should be encrypted at rest in production)
|
||||
access_token = models.TextField(
|
||||
help_text="OAuth access token"
|
||||
)
|
||||
refresh_token = models.TextField(
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="OAuth refresh token (for token renewal)"
|
||||
)
|
||||
token_expiry = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When the access token expires"
|
||||
)
|
||||
|
||||
# Scopes granted
|
||||
scopes = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text="OAuth scopes granted by the user"
|
||||
)
|
||||
|
||||
# Status tracking
|
||||
is_valid = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this credential is currently valid"
|
||||
)
|
||||
last_used_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When this credential was last used"
|
||||
)
|
||||
last_error = models.TextField(
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Last error message if token refresh failed"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['tenant', 'provider', 'purpose']),
|
||||
models.Index(fields=['email']),
|
||||
]
|
||||
# One credential per email/purpose combination per tenant
|
||||
unique_together = [['tenant', 'email', 'purpose']]
|
||||
|
||||
def __str__(self):
|
||||
scope = self.tenant.name if self.tenant else "Platform"
|
||||
return f"{self.email} ({self.get_provider_display()} - {self.get_purpose_display()}) [{scope}]"
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if the access token has expired."""
|
||||
if not self.token_expiry:
|
||||
return False
|
||||
return timezone.now() >= self.token_expiry
|
||||
|
||||
def is_expiring_soon(self, minutes=5):
|
||||
"""Check if the access token will expire within the given minutes."""
|
||||
if not self.token_expiry:
|
||||
return False
|
||||
threshold = timezone.now() + timedelta(minutes=minutes)
|
||||
return threshold >= self.token_expiry
|
||||
|
||||
def needs_refresh(self):
|
||||
"""Check if this credential needs token refresh."""
|
||||
return self.is_expired() or self.is_expiring_soon()
|
||||
|
||||
def mark_used(self):
|
||||
"""Update last_used_at timestamp."""
|
||||
self.last_used_at = timezone.now()
|
||||
self.save(update_fields=['last_used_at'])
|
||||
|
||||
def mark_invalid(self, error_message=''):
|
||||
"""Mark this credential as invalid (e.g., after refresh failure)."""
|
||||
self.is_valid = False
|
||||
self.last_error = error_message
|
||||
self.save(update_fields=['is_valid', 'last_error', 'updated_at'])
|
||||
|
||||
def update_tokens(self, access_token, refresh_token=None, expires_in=None):
|
||||
"""
|
||||
Update tokens after a successful refresh.
|
||||
|
||||
Args:
|
||||
access_token: New access token
|
||||
refresh_token: New refresh token (optional, some providers rotate)
|
||||
expires_in: Token lifetime in seconds
|
||||
"""
|
||||
self.access_token = access_token
|
||||
if refresh_token:
|
||||
self.refresh_token = refresh_token
|
||||
if expires_in:
|
||||
self.token_expiry = timezone.now() + timedelta(seconds=expires_in)
|
||||
self.is_valid = True
|
||||
self.last_error = ''
|
||||
self.save(update_fields=[
|
||||
'access_token', 'refresh_token', 'token_expiry',
|
||||
'is_valid', 'last_error', 'updated_at'
|
||||
])
|
||||
|
||||
|
||||
class TierLimit(models.Model):
|
||||
"""
|
||||
Defines resource limits for each subscription tier.
|
||||
|
||||
406
smoothschedule/core/oauth_service.py
Normal file
406
smoothschedule/core/oauth_service.py
Normal file
@@ -0,0 +1,406 @@
|
||||
"""
|
||||
OAuth Service for Email Integration
|
||||
|
||||
Handles OAuth flows for Google and Microsoft to obtain tokens for
|
||||
IMAP/SMTP XOAUTH2 authentication.
|
||||
|
||||
Google OAuth:
|
||||
- Uses google-auth-oauthlib for OAuth 2.0 flow
|
||||
- Scopes: Gmail IMAP and SMTP access
|
||||
- Supports on-demand token refresh
|
||||
|
||||
Microsoft OAuth:
|
||||
- Uses MSAL (Microsoft Authentication Library)
|
||||
- Scopes: IMAP.AccessAsUser.All, SMTP.Send
|
||||
- Supports on-demand token refresh
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from google.oauth2.credentials import Credentials as GoogleCredentials
|
||||
from google_auth_oauthlib.flow import Flow as GoogleFlow
|
||||
from google.auth.transport.requests import Request as GoogleAuthRequest
|
||||
|
||||
import msal
|
||||
|
||||
from .models import OAuthCredential
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Google OAuth scopes for email access
|
||||
GOOGLE_SCOPES = [
|
||||
'https://mail.google.com/', # Full Gmail access (IMAP, SMTP)
|
||||
'openid',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
]
|
||||
|
||||
# Microsoft OAuth scopes for email access
|
||||
MICROSOFT_SCOPES = [
|
||||
'https://outlook.office.com/IMAP.AccessAsUser.All',
|
||||
'https://outlook.office.com/SMTP.Send',
|
||||
'offline_access', # For refresh tokens
|
||||
'openid',
|
||||
'email',
|
||||
]
|
||||
|
||||
|
||||
class GoogleOAuthService:
|
||||
"""
|
||||
Service for handling Google OAuth flow for email access.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.client_id = getattr(settings, 'GOOGLE_OAUTH_CLIENT_ID', '')
|
||||
self.client_secret = getattr(settings, 'GOOGLE_OAUTH_CLIENT_SECRET', '')
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if Google OAuth is configured."""
|
||||
return bool(self.client_id and self.client_secret)
|
||||
|
||||
def get_authorization_url(self, redirect_uri: str, state: str) -> str:
|
||||
"""
|
||||
Generate the Google OAuth authorization URL.
|
||||
|
||||
Args:
|
||||
redirect_uri: The callback URL after authorization
|
||||
state: CSRF state token
|
||||
|
||||
Returns:
|
||||
Authorization URL to redirect user to
|
||||
"""
|
||||
if not self.is_configured():
|
||||
raise ValueError("Google OAuth is not configured")
|
||||
|
||||
flow = GoogleFlow.from_client_config(
|
||||
{
|
||||
'web': {
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'auth_uri': 'https://accounts.google.com/o/oauth2/auth',
|
||||
'token_uri': 'https://oauth2.googleapis.com/token',
|
||||
}
|
||||
},
|
||||
scopes=GOOGLE_SCOPES,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
|
||||
authorization_url, _ = flow.authorization_url(
|
||||
access_type='offline', # Get refresh token
|
||||
include_granted_scopes='true',
|
||||
state=state,
|
||||
prompt='consent', # Force consent to get refresh token
|
||||
)
|
||||
|
||||
return authorization_url
|
||||
|
||||
def exchange_code_for_tokens(
|
||||
self, code: str, redirect_uri: str
|
||||
) -> dict:
|
||||
"""
|
||||
Exchange authorization code for access and refresh tokens.
|
||||
|
||||
Args:
|
||||
code: Authorization code from callback
|
||||
redirect_uri: Same redirect URI used in authorization
|
||||
|
||||
Returns:
|
||||
Dict with access_token, refresh_token, expires_in, email
|
||||
"""
|
||||
if not self.is_configured():
|
||||
raise ValueError("Google OAuth is not configured")
|
||||
|
||||
flow = GoogleFlow.from_client_config(
|
||||
{
|
||||
'web': {
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'auth_uri': 'https://accounts.google.com/o/oauth2/auth',
|
||||
'token_uri': 'https://oauth2.googleapis.com/token',
|
||||
}
|
||||
},
|
||||
scopes=GOOGLE_SCOPES,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
|
||||
flow.fetch_token(code=code)
|
||||
credentials = flow.credentials
|
||||
|
||||
# Get user email from ID token
|
||||
from google.oauth2 import id_token
|
||||
from google.auth.transport import requests
|
||||
|
||||
try:
|
||||
id_info = id_token.verify_oauth2_token(
|
||||
credentials.id_token,
|
||||
requests.Request(),
|
||||
self.client_id
|
||||
)
|
||||
email = id_info.get('email', '')
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not extract email from ID token: {e}")
|
||||
email = ''
|
||||
|
||||
return {
|
||||
'access_token': credentials.token,
|
||||
'refresh_token': credentials.refresh_token,
|
||||
'expires_in': (credentials.expiry - timezone.now()).total_seconds() if credentials.expiry else 3600,
|
||||
'email': email,
|
||||
'scopes': list(credentials.scopes) if credentials.scopes else GOOGLE_SCOPES,
|
||||
}
|
||||
|
||||
def refresh_token(self, credential: OAuthCredential) -> bool:
|
||||
"""
|
||||
Refresh an expired Google OAuth token.
|
||||
|
||||
Args:
|
||||
credential: OAuthCredential instance to refresh
|
||||
|
||||
Returns:
|
||||
True if refresh successful, False otherwise
|
||||
"""
|
||||
if not credential.refresh_token:
|
||||
logger.error(f"No refresh token for credential {credential.id}")
|
||||
credential.mark_invalid("No refresh token available")
|
||||
return False
|
||||
|
||||
try:
|
||||
creds = GoogleCredentials(
|
||||
token=credential.access_token,
|
||||
refresh_token=credential.refresh_token,
|
||||
token_uri='https://oauth2.googleapis.com/token',
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
)
|
||||
|
||||
creds.refresh(GoogleAuthRequest())
|
||||
|
||||
# Calculate expires_in from expiry datetime
|
||||
expires_in = None
|
||||
if creds.expiry:
|
||||
expires_in = int((creds.expiry - timezone.now()).total_seconds())
|
||||
|
||||
credential.update_tokens(
|
||||
access_token=creds.token,
|
||||
refresh_token=creds.refresh_token, # May be rotated
|
||||
expires_in=expires_in or 3600,
|
||||
)
|
||||
|
||||
logger.info(f"Successfully refreshed Google token for {credential.email}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh Google token for {credential.email}: {e}")
|
||||
credential.mark_invalid(str(e))
|
||||
return False
|
||||
|
||||
|
||||
class MicrosoftOAuthService:
|
||||
"""
|
||||
Service for handling Microsoft OAuth flow for email access.
|
||||
Uses MSAL (Microsoft Authentication Library).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.client_id = getattr(settings, 'MICROSOFT_OAUTH_CLIENT_ID', '')
|
||||
self.client_secret = getattr(settings, 'MICROSOFT_OAUTH_CLIENT_SECRET', '')
|
||||
self.tenant_id = getattr(settings, 'MICROSOFT_OAUTH_TENANT_ID', 'common')
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if Microsoft OAuth is configured."""
|
||||
return bool(self.client_id and self.client_secret)
|
||||
|
||||
@property
|
||||
def authority(self) -> str:
|
||||
"""Get the Microsoft authority URL."""
|
||||
return f'https://login.microsoftonline.com/{self.tenant_id}'
|
||||
|
||||
def _get_msal_app(self) -> msal.ConfidentialClientApplication:
|
||||
"""Create MSAL application instance."""
|
||||
return msal.ConfidentialClientApplication(
|
||||
self.client_id,
|
||||
authority=self.authority,
|
||||
client_credential=self.client_secret,
|
||||
)
|
||||
|
||||
def get_authorization_url(self, redirect_uri: str, state: str) -> str:
|
||||
"""
|
||||
Generate the Microsoft OAuth authorization URL.
|
||||
|
||||
Args:
|
||||
redirect_uri: The callback URL after authorization
|
||||
state: CSRF state token
|
||||
|
||||
Returns:
|
||||
Authorization URL to redirect user to
|
||||
"""
|
||||
if not self.is_configured():
|
||||
raise ValueError("Microsoft OAuth is not configured")
|
||||
|
||||
app = self._get_msal_app()
|
||||
|
||||
auth_url = app.get_authorization_request_url(
|
||||
scopes=MICROSOFT_SCOPES,
|
||||
redirect_uri=redirect_uri,
|
||||
state=state,
|
||||
prompt='consent', # Force consent to get refresh token
|
||||
)
|
||||
|
||||
return auth_url
|
||||
|
||||
def exchange_code_for_tokens(
|
||||
self, code: str, redirect_uri: str
|
||||
) -> dict:
|
||||
"""
|
||||
Exchange authorization code for access and refresh tokens.
|
||||
|
||||
Args:
|
||||
code: Authorization code from callback
|
||||
redirect_uri: Same redirect URI used in authorization
|
||||
|
||||
Returns:
|
||||
Dict with access_token, refresh_token, expires_in, email
|
||||
"""
|
||||
if not self.is_configured():
|
||||
raise ValueError("Microsoft OAuth is not configured")
|
||||
|
||||
app = self._get_msal_app()
|
||||
|
||||
result = app.acquire_token_by_authorization_code(
|
||||
code=code,
|
||||
scopes=MICROSOFT_SCOPES,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
|
||||
if 'error' in result:
|
||||
raise ValueError(f"Token exchange failed: {result.get('error_description', result.get('error'))}")
|
||||
|
||||
# Extract email from ID token claims
|
||||
email = ''
|
||||
if 'id_token_claims' in result:
|
||||
email = result['id_token_claims'].get('preferred_username', '')
|
||||
if not email:
|
||||
email = result['id_token_claims'].get('email', '')
|
||||
|
||||
return {
|
||||
'access_token': result['access_token'],
|
||||
'refresh_token': result.get('refresh_token', ''),
|
||||
'expires_in': result.get('expires_in', 3600),
|
||||
'email': email,
|
||||
'scopes': result.get('scope', '').split() if result.get('scope') else MICROSOFT_SCOPES,
|
||||
}
|
||||
|
||||
def refresh_token(self, credential: OAuthCredential) -> bool:
|
||||
"""
|
||||
Refresh an expired Microsoft OAuth token.
|
||||
|
||||
Args:
|
||||
credential: OAuthCredential instance to refresh
|
||||
|
||||
Returns:
|
||||
True if refresh successful, False otherwise
|
||||
"""
|
||||
if not credential.refresh_token:
|
||||
logger.error(f"No refresh token for credential {credential.id}")
|
||||
credential.mark_invalid("No refresh token available")
|
||||
return False
|
||||
|
||||
try:
|
||||
app = self._get_msal_app()
|
||||
|
||||
# MSAL handles refresh internally when using acquire_token_by_refresh_token
|
||||
result = app.acquire_token_by_refresh_token(
|
||||
refresh_token=credential.refresh_token,
|
||||
scopes=MICROSOFT_SCOPES,
|
||||
)
|
||||
|
||||
if 'error' in result:
|
||||
raise ValueError(result.get('error_description', result.get('error')))
|
||||
|
||||
credential.update_tokens(
|
||||
access_token=result['access_token'],
|
||||
refresh_token=result.get('refresh_token', credential.refresh_token),
|
||||
expires_in=result.get('expires_in', 3600),
|
||||
)
|
||||
|
||||
logger.info(f"Successfully refreshed Microsoft token for {credential.email}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh Microsoft token for {credential.email}: {e}")
|
||||
credential.mark_invalid(str(e))
|
||||
return False
|
||||
|
||||
|
||||
def get_oauth_service(provider: str):
|
||||
"""
|
||||
Factory function to get the appropriate OAuth service.
|
||||
|
||||
Args:
|
||||
provider: 'google' or 'microsoft'
|
||||
|
||||
Returns:
|
||||
OAuth service instance
|
||||
"""
|
||||
if provider == 'google':
|
||||
return GoogleOAuthService()
|
||||
elif provider == 'microsoft':
|
||||
return MicrosoftOAuthService()
|
||||
else:
|
||||
raise ValueError(f"Unknown OAuth provider: {provider}")
|
||||
|
||||
|
||||
def ensure_valid_token(credential: OAuthCredential) -> Optional[str]:
|
||||
"""
|
||||
Ensure the OAuth credential has a valid (non-expired) token.
|
||||
Refreshes if needed.
|
||||
|
||||
Args:
|
||||
credential: OAuthCredential instance
|
||||
|
||||
Returns:
|
||||
Valid access token, or None if refresh failed
|
||||
"""
|
||||
if not credential.is_valid:
|
||||
logger.warning(f"Credential {credential.id} is marked invalid")
|
||||
return None
|
||||
|
||||
if not credential.needs_refresh():
|
||||
credential.mark_used()
|
||||
return credential.access_token
|
||||
|
||||
# Need to refresh
|
||||
logger.info(f"Refreshing token for {credential.email}")
|
||||
service = get_oauth_service(credential.provider)
|
||||
|
||||
if service.refresh_token(credential):
|
||||
credential.mark_used()
|
||||
return credential.access_token
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def generate_xoauth2_string(email: str, access_token: str) -> str:
|
||||
"""
|
||||
Generate XOAUTH2 authentication string for IMAP/SMTP.
|
||||
|
||||
The format is: base64("user=" + email + "^Aauth=Bearer " + access_token + "^A^A")
|
||||
where ^A is ASCII character 0x01
|
||||
|
||||
Args:
|
||||
email: User's email address
|
||||
access_token: OAuth access token
|
||||
|
||||
Returns:
|
||||
Base64-encoded XOAUTH2 string
|
||||
"""
|
||||
import base64
|
||||
|
||||
auth_string = f'user={email}\x01auth=Bearer {access_token}\x01\x01'
|
||||
return base64.b64encode(auth_string.encode()).decode()
|
||||
35
smoothschedule/core/oauth_urls.py
Normal file
35
smoothschedule/core/oauth_urls.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
OAuth URL Configuration
|
||||
|
||||
URL routes for OAuth email integration endpoints.
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from .oauth_views import (
|
||||
OAuthStatusView,
|
||||
GoogleOAuthInitiateView,
|
||||
GoogleOAuthCallbackView,
|
||||
MicrosoftOAuthInitiateView,
|
||||
MicrosoftOAuthCallbackView,
|
||||
OAuthCredentialListView,
|
||||
OAuthCredentialDeleteView,
|
||||
)
|
||||
|
||||
app_name = 'oauth'
|
||||
|
||||
urlpatterns = [
|
||||
# Status
|
||||
path('status/', OAuthStatusView.as_view(), name='status'),
|
||||
|
||||
# Google OAuth
|
||||
path('google/initiate/', GoogleOAuthInitiateView.as_view(), name='google-initiate'),
|
||||
path('google/callback/', GoogleOAuthCallbackView.as_view(), name='google-callback'),
|
||||
|
||||
# Microsoft OAuth
|
||||
path('microsoft/initiate/', MicrosoftOAuthInitiateView.as_view(), name='microsoft-initiate'),
|
||||
path('microsoft/callback/', MicrosoftOAuthCallbackView.as_view(), name='microsoft-callback'),
|
||||
|
||||
# Credential management
|
||||
path('credentials/', OAuthCredentialListView.as_view(), name='credential-list'),
|
||||
path('credentials/<int:credential_id>/', OAuthCredentialDeleteView.as_view(), name='credential-delete'),
|
||||
]
|
||||
405
smoothschedule/core/oauth_views.py
Normal file
405
smoothschedule/core/oauth_views.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
OAuth API Views for Email Integration
|
||||
|
||||
Provides endpoints for initiating OAuth flows and handling callbacks
|
||||
for Google and Microsoft OAuth authentication.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
from django.shortcuts import redirect
|
||||
from django.http import HttpResponse
|
||||
from rest_framework import status
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
from platform_admin.permissions import IsPlatformAdmin
|
||||
from .models import OAuthCredential
|
||||
from .oauth_service import (
|
||||
GoogleOAuthService,
|
||||
MicrosoftOAuthService,
|
||||
get_oauth_service,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_oauth_redirect_uri(request, provider: str) -> str:
|
||||
"""
|
||||
Build the OAuth callback URL.
|
||||
|
||||
For local development, uses lvh.me domain.
|
||||
For production, uses the request's host.
|
||||
"""
|
||||
# Get the base URL from settings or request
|
||||
if settings.DEBUG:
|
||||
base_url = 'http://platform.lvh.me:8000'
|
||||
else:
|
||||
scheme = 'https' if request.is_secure() else 'http'
|
||||
base_url = f'{scheme}://{request.get_host()}'
|
||||
|
||||
return f'{base_url}/api/oauth/{provider}/callback/'
|
||||
|
||||
|
||||
class OAuthStatusView(APIView):
|
||||
"""
|
||||
Check OAuth configuration status.
|
||||
|
||||
GET /api/oauth/status/
|
||||
|
||||
Returns which OAuth providers are configured and available.
|
||||
"""
|
||||
permission_classes = [IsPlatformAdmin]
|
||||
|
||||
def get(self, request):
|
||||
google_service = GoogleOAuthService()
|
||||
microsoft_service = MicrosoftOAuthService()
|
||||
|
||||
return Response({
|
||||
'google': {
|
||||
'configured': google_service.is_configured(),
|
||||
},
|
||||
'microsoft': {
|
||||
'configured': microsoft_service.is_configured(),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
class GoogleOAuthInitiateView(APIView):
|
||||
"""
|
||||
Initiate Google OAuth flow for email access.
|
||||
|
||||
POST /api/oauth/google/initiate/
|
||||
Body: { "purpose": "email" }
|
||||
|
||||
Returns authorization URL to redirect user to.
|
||||
"""
|
||||
permission_classes = [IsPlatformAdmin]
|
||||
|
||||
def post(self, request):
|
||||
purpose = request.data.get('purpose', 'email')
|
||||
|
||||
service = GoogleOAuthService()
|
||||
if not service.is_configured():
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'Google OAuth is not configured. Please add GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET to settings.',
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Generate state token for CSRF protection
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# Store state in session
|
||||
request.session['oauth_state'] = state
|
||||
request.session['oauth_purpose'] = purpose
|
||||
request.session['oauth_provider'] = 'google'
|
||||
|
||||
redirect_uri = get_oauth_redirect_uri(request, 'google')
|
||||
|
||||
try:
|
||||
auth_url = service.get_authorization_url(
|
||||
redirect_uri=redirect_uri,
|
||||
state=state,
|
||||
)
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'authorization_url': auth_url,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate Google OAuth URL: {e}")
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
class GoogleOAuthCallbackView(APIView):
|
||||
"""
|
||||
Handle Google OAuth callback.
|
||||
|
||||
GET /api/oauth/google/callback/?code=...&state=...
|
||||
|
||||
Exchanges code for tokens and saves credential.
|
||||
Redirects to frontend with success/error status.
|
||||
"""
|
||||
permission_classes = [] # Public endpoint (callback from Google)
|
||||
|
||||
def get(self, request):
|
||||
code = request.GET.get('code')
|
||||
state = request.GET.get('state')
|
||||
error = request.GET.get('error')
|
||||
|
||||
# Frontend URL to redirect to after OAuth
|
||||
frontend_base = settings.FRONTEND_URL if hasattr(settings, 'FRONTEND_URL') else 'http://platform.lvh.me:5173'
|
||||
success_url = f'{frontend_base}/platform/settings?oauth=success&provider=google'
|
||||
error_url = f'{frontend_base}/platform/settings?oauth=error&provider=google'
|
||||
|
||||
if error:
|
||||
logger.warning(f"Google OAuth error: {error}")
|
||||
return redirect(f'{error_url}&message={error}')
|
||||
|
||||
# Verify state
|
||||
expected_state = request.session.get('oauth_state')
|
||||
if not state or state != expected_state:
|
||||
logger.warning("Google OAuth state mismatch")
|
||||
return redirect(f'{error_url}&message=invalid_state')
|
||||
|
||||
if not code:
|
||||
return redirect(f'{error_url}&message=no_code')
|
||||
|
||||
service = GoogleOAuthService()
|
||||
redirect_uri = get_oauth_redirect_uri(request, 'google')
|
||||
|
||||
try:
|
||||
# Exchange code for tokens
|
||||
tokens = service.exchange_code_for_tokens(
|
||||
code=code,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
|
||||
email = tokens.get('email', '')
|
||||
if not email:
|
||||
return redirect(f'{error_url}&message=no_email')
|
||||
|
||||
purpose = request.session.get('oauth_purpose', 'email')
|
||||
|
||||
# Save or update credential
|
||||
credential, created = OAuthCredential.objects.update_or_create(
|
||||
tenant=None, # Platform-level credential
|
||||
email=email,
|
||||
purpose=purpose,
|
||||
defaults={
|
||||
'provider': 'google',
|
||||
'access_token': tokens['access_token'],
|
||||
'refresh_token': tokens.get('refresh_token', ''),
|
||||
'token_expiry': tokens.get('expires_in'),
|
||||
'scopes': tokens.get('scopes', []),
|
||||
'is_valid': True,
|
||||
'authorized_by': request.user if request.user.is_authenticated else None,
|
||||
}
|
||||
)
|
||||
|
||||
# Set token expiry properly
|
||||
if tokens.get('expires_in'):
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
credential.token_expiry = timezone.now() + timedelta(seconds=tokens['expires_in'])
|
||||
credential.save(update_fields=['token_expiry'])
|
||||
|
||||
# Clear session
|
||||
request.session.pop('oauth_state', None)
|
||||
request.session.pop('oauth_purpose', None)
|
||||
request.session.pop('oauth_provider', None)
|
||||
|
||||
logger.info(f"Successfully stored Google OAuth credential for {email}")
|
||||
return redirect(f'{success_url}&email={email}')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Google OAuth callback error: {e}")
|
||||
return redirect(f'{error_url}&message={str(e)[:100]}')
|
||||
|
||||
|
||||
class MicrosoftOAuthInitiateView(APIView):
|
||||
"""
|
||||
Initiate Microsoft OAuth flow for email access.
|
||||
|
||||
POST /api/oauth/microsoft/initiate/
|
||||
Body: { "purpose": "email" }
|
||||
|
||||
Returns authorization URL to redirect user to.
|
||||
"""
|
||||
permission_classes = [IsPlatformAdmin]
|
||||
|
||||
def post(self, request):
|
||||
purpose = request.data.get('purpose', 'email')
|
||||
|
||||
service = MicrosoftOAuthService()
|
||||
if not service.is_configured():
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'Microsoft OAuth is not configured. Please add MICROSOFT_OAUTH_CLIENT_ID and MICROSOFT_OAUTH_CLIENT_SECRET to settings.',
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Generate state token for CSRF protection
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# Store state in session
|
||||
request.session['oauth_state'] = state
|
||||
request.session['oauth_purpose'] = purpose
|
||||
request.session['oauth_provider'] = 'microsoft'
|
||||
|
||||
redirect_uri = get_oauth_redirect_uri(request, 'microsoft')
|
||||
|
||||
try:
|
||||
auth_url = service.get_authorization_url(
|
||||
redirect_uri=redirect_uri,
|
||||
state=state,
|
||||
)
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'authorization_url': auth_url,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate Microsoft OAuth URL: {e}")
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
class MicrosoftOAuthCallbackView(APIView):
|
||||
"""
|
||||
Handle Microsoft OAuth callback.
|
||||
|
||||
GET /api/oauth/microsoft/callback/?code=...&state=...
|
||||
|
||||
Exchanges code for tokens and saves credential.
|
||||
Redirects to frontend with success/error status.
|
||||
"""
|
||||
permission_classes = [] # Public endpoint (callback from Microsoft)
|
||||
|
||||
def get(self, request):
|
||||
code = request.GET.get('code')
|
||||
state = request.GET.get('state')
|
||||
error = request.GET.get('error')
|
||||
error_description = request.GET.get('error_description', '')
|
||||
|
||||
# Frontend URL to redirect to after OAuth
|
||||
frontend_base = settings.FRONTEND_URL if hasattr(settings, 'FRONTEND_URL') else 'http://platform.lvh.me:5173'
|
||||
success_url = f'{frontend_base}/platform/settings?oauth=success&provider=microsoft'
|
||||
error_url = f'{frontend_base}/platform/settings?oauth=error&provider=microsoft'
|
||||
|
||||
if error:
|
||||
logger.warning(f"Microsoft OAuth error: {error} - {error_description}")
|
||||
return redirect(f'{error_url}&message={error}')
|
||||
|
||||
# Verify state
|
||||
expected_state = request.session.get('oauth_state')
|
||||
if not state or state != expected_state:
|
||||
logger.warning("Microsoft OAuth state mismatch")
|
||||
return redirect(f'{error_url}&message=invalid_state')
|
||||
|
||||
if not code:
|
||||
return redirect(f'{error_url}&message=no_code')
|
||||
|
||||
service = MicrosoftOAuthService()
|
||||
redirect_uri = get_oauth_redirect_uri(request, 'microsoft')
|
||||
|
||||
try:
|
||||
# Exchange code for tokens
|
||||
tokens = service.exchange_code_for_tokens(
|
||||
code=code,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
|
||||
email = tokens.get('email', '')
|
||||
if not email:
|
||||
return redirect(f'{error_url}&message=no_email')
|
||||
|
||||
purpose = request.session.get('oauth_purpose', 'email')
|
||||
|
||||
# Save or update credential
|
||||
credential, created = OAuthCredential.objects.update_or_create(
|
||||
tenant=None, # Platform-level credential
|
||||
email=email,
|
||||
purpose=purpose,
|
||||
defaults={
|
||||
'provider': 'microsoft',
|
||||
'access_token': tokens['access_token'],
|
||||
'refresh_token': tokens.get('refresh_token', ''),
|
||||
'scopes': tokens.get('scopes', []),
|
||||
'is_valid': True,
|
||||
'authorized_by': request.user if request.user.is_authenticated else None,
|
||||
}
|
||||
)
|
||||
|
||||
# Set token expiry properly
|
||||
if tokens.get('expires_in'):
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
credential.token_expiry = timezone.now() + timedelta(seconds=tokens['expires_in'])
|
||||
credential.save(update_fields=['token_expiry'])
|
||||
|
||||
# Clear session
|
||||
request.session.pop('oauth_state', None)
|
||||
request.session.pop('oauth_purpose', None)
|
||||
request.session.pop('oauth_provider', None)
|
||||
|
||||
logger.info(f"Successfully stored Microsoft OAuth credential for {email}")
|
||||
return redirect(f'{success_url}&email={email}')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Microsoft OAuth callback error: {e}")
|
||||
return redirect(f'{error_url}&message={str(e)[:100]}')
|
||||
|
||||
|
||||
class OAuthCredentialListView(APIView):
|
||||
"""
|
||||
List OAuth credentials.
|
||||
|
||||
GET /api/oauth/credentials/
|
||||
|
||||
Returns list of stored OAuth credentials (tokens are masked).
|
||||
"""
|
||||
permission_classes = [IsPlatformAdmin]
|
||||
|
||||
def get(self, request):
|
||||
credentials = OAuthCredential.objects.filter(
|
||||
tenant=None, # Platform-level only
|
||||
purpose='email',
|
||||
).order_by('-created_at')
|
||||
|
||||
return Response([
|
||||
{
|
||||
'id': cred.id,
|
||||
'provider': cred.provider,
|
||||
'email': cred.email,
|
||||
'purpose': cred.purpose,
|
||||
'is_valid': cred.is_valid,
|
||||
'is_expired': cred.is_expired(),
|
||||
'last_used_at': cred.last_used_at,
|
||||
'last_error': cred.last_error,
|
||||
'created_at': cred.created_at,
|
||||
}
|
||||
for cred in credentials
|
||||
])
|
||||
|
||||
|
||||
class OAuthCredentialDeleteView(APIView):
|
||||
"""
|
||||
Delete/revoke an OAuth credential.
|
||||
|
||||
DELETE /api/oauth/credentials/{id}/
|
||||
|
||||
Removes the stored credential.
|
||||
"""
|
||||
permission_classes = [IsPlatformAdmin]
|
||||
|
||||
def delete(self, request, credential_id):
|
||||
try:
|
||||
credential = OAuthCredential.objects.get(
|
||||
id=credential_id,
|
||||
tenant=None, # Platform-level only
|
||||
)
|
||||
email = credential.email
|
||||
credential.delete()
|
||||
|
||||
logger.info(f"Deleted OAuth credential for {email}")
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': f'Credential for {email} deleted',
|
||||
})
|
||||
|
||||
except OAuthCredential.DoesNotExist:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'Credential not found',
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
@@ -198,4 +198,8 @@ dependencies = [
|
||||
"dj-stripe>=2.9.0",
|
||||
"django-csp==3.8.0",
|
||||
"twilio>=9.0.0",
|
||||
"dnspython>=2.6.0",
|
||||
"google-auth>=2.0.0",
|
||||
"google-auth-oauthlib>=1.0.0",
|
||||
"msal>=1.24.0",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-30 00:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0010_add_oauth_credential_model'),
|
||||
('tickets', '0008_add_smtp_settings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ticketemailsettings',
|
||||
name='oauth_credential',
|
||||
field=models.ForeignKey(blank=True, help_text='OAuth credential for XOAUTH2 authentication (Gmail/Microsoft)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_email_settings', to='core.oauthcredential'),
|
||||
),
|
||||
]
|
||||
@@ -497,6 +497,16 @@ class TicketEmailSettings(models.Model):
|
||||
help_text="Total number of emails processed"
|
||||
)
|
||||
|
||||
# OAuth credential for XOAUTH2 authentication (alternative to password)
|
||||
oauth_credential = models.ForeignKey(
|
||||
'core.OAuthCredential',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='ticket_email_settings',
|
||||
help_text="OAuth credential for XOAUTH2 authentication (Gmail/Microsoft)"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -523,22 +533,26 @@ class TicketEmailSettings(models.Model):
|
||||
instance, _ = cls.objects.get_or_create(pk=1)
|
||||
return instance
|
||||
|
||||
def uses_oauth(self):
|
||||
"""Check if using OAuth for authentication."""
|
||||
return self.oauth_credential is not None and self.oauth_credential.is_valid
|
||||
|
||||
def is_imap_configured(self):
|
||||
"""Check if IMAP (inbound) settings are properly configured."""
|
||||
return bool(
|
||||
self.imap_host and
|
||||
self.imap_username and
|
||||
self.imap_password
|
||||
)
|
||||
has_host = bool(self.imap_host)
|
||||
has_username = bool(self.imap_username)
|
||||
# Either password or OAuth credential is required
|
||||
has_auth = bool(self.imap_password) or self.uses_oauth()
|
||||
return has_host and has_username and has_auth
|
||||
|
||||
def is_smtp_configured(self):
|
||||
"""Check if SMTP (outbound) settings are properly configured."""
|
||||
return bool(
|
||||
self.smtp_host and
|
||||
self.smtp_username and
|
||||
self.smtp_password and
|
||||
self.smtp_from_email
|
||||
)
|
||||
has_host = bool(self.smtp_host)
|
||||
has_username = bool(self.smtp_username)
|
||||
has_from = bool(self.smtp_from_email)
|
||||
# Either password or OAuth credential is required
|
||||
has_auth = bool(self.smtp_password) or self.uses_oauth()
|
||||
return has_host and has_username and has_from and has_auth
|
||||
|
||||
def is_configured(self):
|
||||
"""Check if email settings are properly configured (both IMAP and SMTP)."""
|
||||
|
||||
@@ -5,7 +5,7 @@ from .views import (
|
||||
TicketTemplateViewSet, CannedResponseViewSet,
|
||||
TicketEmailSettingsView, TicketEmailTestConnectionView,
|
||||
TicketEmailTestSmtpView, TicketEmailFetchNowView,
|
||||
IncomingTicketEmailViewSet
|
||||
IncomingTicketEmailViewSet, EmailProviderDetectView
|
||||
)
|
||||
|
||||
app_name = 'tickets'
|
||||
@@ -35,6 +35,7 @@ incoming_emails_router.register(r'', IncomingTicketEmailViewSet, basename='incom
|
||||
urlpatterns = [
|
||||
# Email settings endpoints (platform admin only) - must be BEFORE router.urls
|
||||
path('email-settings/', TicketEmailSettingsView.as_view(), name='email-settings'),
|
||||
path('email-settings/detect/', EmailProviderDetectView.as_view(), name='email-detect'),
|
||||
path('email-settings/test-imap/', TicketEmailTestConnectionView.as_view(), name='email-test-imap'),
|
||||
path('email-settings/test-smtp/', TicketEmailTestSmtpView.as_view(), name='email-test-smtp'),
|
||||
path('email-settings/fetch-now/', TicketEmailFetchNowView.as_view(), name='email-fetch-now'),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import dns.resolver
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
@@ -645,4 +646,297 @@ class IncomingTicketEmailViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
return Response({
|
||||
'success': False,
|
||||
'message': f'Error reprocessing: {str(e)}',
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
# Email provider database for auto-detection
|
||||
EMAIL_PROVIDERS = {
|
||||
# Gmail
|
||||
'gmail.com': {
|
||||
'provider': 'google',
|
||||
'display_name': 'Gmail',
|
||||
'imap_host': 'imap.gmail.com',
|
||||
'imap_port': 993,
|
||||
'smtp_host': 'smtp.gmail.com',
|
||||
'smtp_port': 587,
|
||||
'oauth_supported': True,
|
||||
},
|
||||
'googlemail.com': {
|
||||
'provider': 'google',
|
||||
'display_name': 'Gmail',
|
||||
'imap_host': 'imap.gmail.com',
|
||||
'imap_port': 993,
|
||||
'smtp_host': 'smtp.gmail.com',
|
||||
'smtp_port': 587,
|
||||
'oauth_supported': True,
|
||||
},
|
||||
# Microsoft
|
||||
'outlook.com': {
|
||||
'provider': 'microsoft',
|
||||
'display_name': 'Outlook.com',
|
||||
'imap_host': 'outlook.office365.com',
|
||||
'imap_port': 993,
|
||||
'smtp_host': 'smtp.office365.com',
|
||||
'smtp_port': 587,
|
||||
'oauth_supported': True,
|
||||
},
|
||||
'hotmail.com': {
|
||||
'provider': 'microsoft',
|
||||
'display_name': 'Hotmail',
|
||||
'imap_host': 'outlook.office365.com',
|
||||
'imap_port': 993,
|
||||
'smtp_host': 'smtp.office365.com',
|
||||
'smtp_port': 587,
|
||||
'oauth_supported': True,
|
||||
},
|
||||
'live.com': {
|
||||
'provider': 'microsoft',
|
||||
'display_name': 'Live',
|
||||
'imap_host': 'outlook.office365.com',
|
||||
'imap_port': 993,
|
||||
'smtp_host': 'smtp.office365.com',
|
||||
'smtp_port': 587,
|
||||
'oauth_supported': True,
|
||||
},
|
||||
'msn.com': {
|
||||
'provider': 'microsoft',
|
||||
'display_name': 'MSN',
|
||||
'imap_host': 'outlook.office365.com',
|
||||
'imap_port': 993,
|
||||
'smtp_host': 'smtp.office365.com',
|
||||
'smtp_port': 587,
|
||||
'oauth_supported': True,
|
||||
},
|
||||
# Yahoo
|
||||
'yahoo.com': {
|
||||
'provider': 'yahoo',
|
||||
'display_name': 'Yahoo Mail',
|
||||
'imap_host': 'imap.mail.yahoo.com',
|
||||
'imap_port': 993,
|
||||
'smtp_host': 'smtp.mail.yahoo.com',
|
||||
'smtp_port': 587,
|
||||
'oauth_supported': False,
|
||||
},
|
||||
'yahoo.co.uk': {
|
||||
'provider': 'yahoo',
|
||||
'display_name': 'Yahoo Mail UK',
|
||||
'imap_host': 'imap.mail.yahoo.com',
|
||||
'imap_port': 993,
|
||||
'smtp_host': 'smtp.mail.yahoo.com',
|
||||
'smtp_port': 587,
|
||||
'oauth_supported': False,
|
||||
},
|
||||
# Apple iCloud
|
||||
'icloud.com': {
|
||||
'provider': 'apple',
|
||||
'display_name': 'iCloud Mail',
|
||||
'imap_host': 'imap.mail.me.com',
|
||||
'imap_port': 993,
|
||||
'smtp_host': 'smtp.mail.me.com',
|
||||
'smtp_port': 587,
|
||||
'oauth_supported': False,
|
||||
},
|
||||
'me.com': {
|
||||
'provider': 'apple',
|
||||
'display_name': 'iCloud Mail',
|
||||
'imap_host': 'imap.mail.me.com',
|
||||
'imap_port': 993,
|
||||
'smtp_host': 'smtp.mail.me.com',
|
||||
'smtp_port': 587,
|
||||
'oauth_supported': False,
|
||||
},
|
||||
'mac.com': {
|
||||
'provider': 'apple',
|
||||
'display_name': 'iCloud Mail',
|
||||
'imap_host': 'imap.mail.me.com',
|
||||
'imap_port': 993,
|
||||
'smtp_host': 'smtp.mail.me.com',
|
||||
'smtp_port': 587,
|
||||
'oauth_supported': False,
|
||||
},
|
||||
# AOL
|
||||
'aol.com': {
|
||||
'provider': 'aol',
|
||||
'display_name': 'AOL Mail',
|
||||
'imap_host': 'imap.aol.com',
|
||||
'imap_port': 993,
|
||||
'smtp_host': 'smtp.aol.com',
|
||||
'smtp_port': 587,
|
||||
'oauth_supported': False,
|
||||
},
|
||||
# Zoho
|
||||
'zoho.com': {
|
||||
'provider': 'zoho',
|
||||
'display_name': 'Zoho Mail',
|
||||
'imap_host': 'imap.zoho.com',
|
||||
'imap_port': 993,
|
||||
'smtp_host': 'smtp.zoho.com',
|
||||
'smtp_port': 587,
|
||||
'oauth_supported': False,
|
||||
},
|
||||
# ProtonMail (Bridge required)
|
||||
'protonmail.com': {
|
||||
'provider': 'protonmail',
|
||||
'display_name': 'ProtonMail',
|
||||
'imap_host': '127.0.0.1',
|
||||
'imap_port': 1143,
|
||||
'smtp_host': '127.0.0.1',
|
||||
'smtp_port': 1025,
|
||||
'oauth_supported': False,
|
||||
'notes': 'Requires ProtonMail Bridge application',
|
||||
},
|
||||
'proton.me': {
|
||||
'provider': 'protonmail',
|
||||
'display_name': 'Proton Mail',
|
||||
'imap_host': '127.0.0.1',
|
||||
'imap_port': 1143,
|
||||
'smtp_host': '127.0.0.1',
|
||||
'smtp_port': 1025,
|
||||
'oauth_supported': False,
|
||||
'notes': 'Requires ProtonMail Bridge application',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def detect_provider_from_mx(domain: str) -> dict | None:
|
||||
"""
|
||||
Detect email provider from MX records for custom domains.
|
||||
Used to identify Google Workspace or Microsoft 365 hosted domains.
|
||||
"""
|
||||
try:
|
||||
mx_records = dns.resolver.resolve(domain, 'MX')
|
||||
mx_hosts = [str(record.exchange).lower() for record in mx_records]
|
||||
|
||||
# Check for Google Workspace
|
||||
for mx in mx_hosts:
|
||||
if 'google' in mx or 'googlemail' in mx:
|
||||
return {
|
||||
'provider': 'google',
|
||||
'display_name': 'Google Workspace',
|
||||
'imap_host': 'imap.gmail.com',
|
||||
'imap_port': 993,
|
||||
'smtp_host': 'smtp.gmail.com',
|
||||
'smtp_port': 587,
|
||||
'oauth_supported': True,
|
||||
'detected_via': 'mx_record',
|
||||
}
|
||||
|
||||
# Check for Microsoft 365
|
||||
for mx in mx_hosts:
|
||||
if 'outlook' in mx or 'microsoft' in mx or 'office365' in mx:
|
||||
return {
|
||||
'provider': 'microsoft',
|
||||
'display_name': 'Microsoft 365',
|
||||
'imap_host': 'outlook.office365.com',
|
||||
'imap_port': 993,
|
||||
'smtp_host': 'smtp.office365.com',
|
||||
'smtp_port': 587,
|
||||
'oauth_supported': True,
|
||||
'detected_via': 'mx_record',
|
||||
}
|
||||
|
||||
# Check for Zoho
|
||||
for mx in mx_hosts:
|
||||
if 'zoho' in mx:
|
||||
return {
|
||||
'provider': 'zoho',
|
||||
'display_name': 'Zoho Mail',
|
||||
'imap_host': 'imap.zoho.com',
|
||||
'imap_port': 993,
|
||||
'smtp_host': 'smtp.zoho.com',
|
||||
'smtp_port': 587,
|
||||
'oauth_supported': False,
|
||||
'detected_via': 'mx_record',
|
||||
}
|
||||
|
||||
# Check for Yahoo/AT&T (they use Yahoo infrastructure)
|
||||
for mx in mx_hosts:
|
||||
if 'yahoodns' in mx or 'yahoo' in mx:
|
||||
return {
|
||||
'provider': 'yahoo',
|
||||
'display_name': 'Yahoo Mail',
|
||||
'imap_host': 'imap.mail.yahoo.com',
|
||||
'imap_port': 993,
|
||||
'smtp_host': 'smtp.mail.yahoo.com',
|
||||
'smtp_port': 587,
|
||||
'oauth_supported': False,
|
||||
'detected_via': 'mx_record',
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers):
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class EmailProviderDetectView(APIView):
|
||||
"""
|
||||
API endpoint to detect email provider settings from an email address.
|
||||
Used by the email configuration wizard for auto-detection.
|
||||
|
||||
POST /api/tickets/email-settings/detect/
|
||||
Body: { "email": "support@company.com" }
|
||||
|
||||
Returns detected provider info and suggested IMAP/SMTP settings.
|
||||
"""
|
||||
permission_classes = [IsPlatformAdmin]
|
||||
|
||||
def post(self, request):
|
||||
email = request.data.get('email', '').strip().lower()
|
||||
|
||||
if not email or '@' not in email:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'Valid email address required',
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Extract domain from email
|
||||
try:
|
||||
domain = email.split('@')[1]
|
||||
except IndexError:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'Invalid email format',
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# First check known providers
|
||||
provider_info = EMAIL_PROVIDERS.get(domain)
|
||||
|
||||
if provider_info:
|
||||
return Response({
|
||||
'success': True,
|
||||
'email': email,
|
||||
'domain': domain,
|
||||
'detected': True,
|
||||
'detected_via': 'domain_lookup',
|
||||
**provider_info,
|
||||
})
|
||||
|
||||
# For custom domains, try MX record lookup
|
||||
mx_provider = detect_provider_from_mx(domain)
|
||||
|
||||
if mx_provider:
|
||||
return Response({
|
||||
'success': True,
|
||||
'email': email,
|
||||
'domain': domain,
|
||||
'detected': True,
|
||||
**mx_provider,
|
||||
})
|
||||
|
||||
# Unknown provider - return generic settings hint
|
||||
return Response({
|
||||
'success': True,
|
||||
'email': email,
|
||||
'domain': domain,
|
||||
'detected': False,
|
||||
'provider': 'unknown',
|
||||
'display_name': 'Custom/Unknown',
|
||||
'oauth_supported': False,
|
||||
'message': 'Could not auto-detect provider. Please enter IMAP/SMTP settings manually.',
|
||||
# Provide common default port suggestions
|
||||
'suggested_imap_port': 993,
|
||||
'suggested_smtp_port': 587,
|
||||
})
|
||||
127
smoothschedule/uv.lock
generated
127
smoothschedule/uv.lock
generated
@@ -209,6 +209,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/4e/21cd0b8f365449f1576f93de1ec8718ed18a7a3bc086dfbdeb79437bba7a/botocore-1.41.5-py3-none-any.whl", hash = "sha256:3fef7fcda30c82c27202d232cfdbd6782cb27f20f8e7e21b20606483e66ee73a", size = 14337008, upload-time = "2025-11-26T20:27:35.208Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cachetools"
|
||||
version = "6.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/44/ca1675be2a83aeee1886ab745b28cda92093066590233cc501890eb8417a/cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6", size = 31571, upload-time = "2025-11-13T17:42:51.465Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "celery"
|
||||
version = "5.5.3"
|
||||
@@ -837,6 +846,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd", size = 52290, upload-time = "2024-12-24T13:06:33.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docutils"
|
||||
version = "0.21.2"
|
||||
@@ -983,6 +1001,33 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-auth"
|
||||
version = "2.43.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cachetools" },
|
||||
{ name = "pyasn1-modules" },
|
||||
{ name = "rsa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/ef/66d14cf0e01b08d2d51ffc3c20410c4e134a1548fc246a6081eae585a4fe/google_auth-2.43.0.tar.gz", hash = "sha256:88228eee5fc21b62a1b5fe773ca15e67778cb07dc8363adcb4a8827b52d81483", size = 296359, upload-time = "2025-11-06T00:13:36.587Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/d1/385110a9ae86d91cc14c5282c61fe9f4dc41c0b9f7d423c6ad77038c4448/google_auth-2.43.0-py2.py3-none-any.whl", hash = "sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16", size = 223114, upload-time = "2025-11-06T00:13:35.209Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-auth-oauthlib"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-auth" },
|
||||
{ name = "requests-oauthlib" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/87/e10bf24f7bcffc1421b84d6f9c3377c30ec305d082cd737ddaa6d8f77f7c/google_auth_oauthlib-1.2.2.tar.gz", hash = "sha256:11046fb8d3348b296302dd939ace8af0a724042e8029c1b872d87fabc9f41684", size = 20955, upload-time = "2025-04-22T16:40:29.172Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/84/40ee070be95771acd2f4418981edb834979424565c3eec3cd88b6aa09d24/google_auth_oauthlib-1.2.2-py3-none-any.whl", hash = "sha256:fd619506f4b3908b5df17b65f39ca8d66ea56986e5472eb5978fd8f3786f00a2", size = 19072, upload-time = "2025-04-22T16:40:28.174Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.2.4"
|
||||
@@ -1283,6 +1328,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "msal"
|
||||
version = "1.34.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/dc/18d48843499e278538890dc709e9ee3dea8375f8be8e82682851df1b48b5/msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1", size = 116987, upload-time = "2025-09-22T23:05:47.294Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "msgpack"
|
||||
version = "1.1.2"
|
||||
@@ -1383,6 +1442,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oauthlib"
|
||||
version = "3.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
@@ -1609,6 +1677,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1-modules"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.23"
|
||||
@@ -1648,6 +1737,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
crypto = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.1"
|
||||
@@ -1834,6 +1928,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests-oauthlib"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "oauthlib" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roman-numerals-py"
|
||||
version = "3.1.0"
|
||||
@@ -1880,6 +1987,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/3a/12dc43f13594a54ea0c9d7e9d43002116557330e3ad45bc56097ddf266e2/rpds_py-0.29.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f49196aec7c4b406495f60e6f947ad71f317a765f956d74bbd83996b9edc0352", size = 225248, upload-time = "2025-11-16T14:49:24.841Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.14.6"
|
||||
@@ -1965,10 +2084,14 @@ dependencies = [
|
||||
{ name = "django-storages", extra = ["s3"] },
|
||||
{ name = "django-tenants" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "dnspython" },
|
||||
{ name = "drf-spectacular" },
|
||||
{ name = "flower" },
|
||||
{ name = "google-auth" },
|
||||
{ name = "google-auth-oauthlib" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "hiredis" },
|
||||
{ name = "msal" },
|
||||
{ name = "pillow" },
|
||||
{ name = "psycopg", extra = ["c"] },
|
||||
{ name = "python-slugify" },
|
||||
@@ -2026,10 +2149,14 @@ requires-dist = [
|
||||
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
|
||||
{ name = "django-tenants", specifier = ">=3.6" },
|
||||
{ name = "djangorestframework", specifier = "==3.16.1" },
|
||||
{ name = "dnspython", specifier = ">=2.6.0" },
|
||||
{ name = "drf-spectacular", specifier = "==0.29.0" },
|
||||
{ name = "flower", specifier = "==2.0.1" },
|
||||
{ name = "google-auth", specifier = ">=2.0.0" },
|
||||
{ name = "google-auth-oauthlib", specifier = ">=1.0.0" },
|
||||
{ name = "gunicorn", specifier = "==23.0.0" },
|
||||
{ name = "hiredis", specifier = "==3.3.0" },
|
||||
{ name = "msal", specifier = ">=1.24.0" },
|
||||
{ name = "pillow", specifier = "==12.0.0" },
|
||||
{ name = "psycopg", extras = ["c"], specifier = "==3.2.13" },
|
||||
{ name = "python-slugify", specifier = "==8.0.4" },
|
||||
|
||||
Reference in New Issue
Block a user