REFACTOR API gateway
This commit is contained in:
@@ -99,7 +99,7 @@ export class DataService {
|
|||||||
*/
|
*/
|
||||||
async uploadSalesHistory(
|
async uploadSalesHistory(
|
||||||
file: File,
|
file: File,
|
||||||
tenantId?: string,
|
tenantId: string, // Tenant ID is now a required path parameter
|
||||||
additionalData?: Record<string, any>
|
additionalData?: Record<string, any>
|
||||||
): Promise<UploadResponse> {
|
): Promise<UploadResponse> {
|
||||||
try {
|
try {
|
||||||
@@ -107,7 +107,7 @@ export class DataService {
|
|||||||
|
|
||||||
// ✅ CRITICAL FIX: Use the correct endpoint that exists in backend
|
// ✅ CRITICAL FIX: Use the correct endpoint that exists in backend
|
||||||
// Backend endpoint: @router.post("/import", response_model=SalesImportResult)
|
// Backend endpoint: @router.post("/import", response_model=SalesImportResult)
|
||||||
// Full path: /api/v1/data/sales/import (mounted with prefix /api/v1/sales)
|
// Full path: /api/v1/tenants/{tenant_id}/sales/import
|
||||||
|
|
||||||
// Determine file format
|
// Determine file format
|
||||||
const fileName = file.name.toLowerCase();
|
const fileName = file.name.toLowerCase();
|
||||||
@@ -128,9 +128,10 @@ export class DataService {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('file_format', fileFormat);
|
formData.append('file_format', fileFormat);
|
||||||
|
|
||||||
if (tenantId) {
|
// tenantId is no longer appended to FormData as it's a path parameter
|
||||||
formData.append('tenant_id', tenantId);
|
// if (tenantId) {
|
||||||
}
|
// formData.append('tenant_id', tenantId);
|
||||||
|
// }
|
||||||
|
|
||||||
// Add additional data if provided
|
// Add additional data if provided
|
||||||
if (additionalData) {
|
if (additionalData) {
|
||||||
@@ -143,7 +144,7 @@ export class DataService {
|
|||||||
|
|
||||||
// ✅ FIXED: Use the correct endpoint that exists in the backend
|
// ✅ FIXED: Use the correct endpoint that exists in the backend
|
||||||
const response = await apiClient.request<ApiResponse<any>>(
|
const response = await apiClient.request<ApiResponse<any>>(
|
||||||
'/api/v1/data/sales/import', // Correct endpoint path
|
`/api/v1/tenants/${tenantId}/sales/import`, // Correct endpoint path with tenant_id
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
@@ -216,7 +217,7 @@ export class DataService {
|
|||||||
* ✅ ALTERNATIVE: Upload sales data using the JSON import endpoint
|
* ✅ ALTERNATIVE: Upload sales data using the JSON import endpoint
|
||||||
* This uses the same endpoint as validation but with validate_only: false
|
* This uses the same endpoint as validation but with validate_only: false
|
||||||
*/
|
*/
|
||||||
async uploadSalesDataAsJson(file: File, tenantId?: string): Promise<UploadResponse> {
|
async uploadSalesDataAsJson(file: File, tenantId: string): Promise<UploadResponse> { // tenantId made required
|
||||||
try {
|
try {
|
||||||
console.log('Uploading sales data as JSON:', file.name);
|
console.log('Uploading sales data as JSON:', file.name);
|
||||||
|
|
||||||
@@ -242,7 +243,7 @@ export class DataService {
|
|||||||
|
|
||||||
// ✅ Use the same structure as validation but with validate_only: false
|
// ✅ Use the same structure as validation but with validate_only: false
|
||||||
const importData: SalesDataImportRequest = {
|
const importData: SalesDataImportRequest = {
|
||||||
tenant_id: tenantId || '00000000-0000-0000-0000-000000000000',
|
tenant_id: tenantId, // Use the provided tenantId
|
||||||
data: fileContent,
|
data: fileContent,
|
||||||
data_format: dataFormat,
|
data_format: dataFormat,
|
||||||
validate_only: false, // This makes it actually import the data
|
validate_only: false, // This makes it actually import the data
|
||||||
@@ -252,8 +253,16 @@ export class DataService {
|
|||||||
console.log('Uploading data with validate_only: false');
|
console.log('Uploading data with validate_only: false');
|
||||||
|
|
||||||
// ✅ OPTION: Add a new JSON import endpoint to the backend
|
// ✅ OPTION: Add a new JSON import endpoint to the backend
|
||||||
|
// Current backend sales.py does not have a /import/json endpoint,
|
||||||
|
// it only has a file upload endpoint.
|
||||||
|
// If a JSON import endpoint is desired, it needs to be added to sales.py
|
||||||
|
// For now, this method will target the existing /import endpoint with a JSON body
|
||||||
|
// This will require the backend to support JSON body for /import, which it currently
|
||||||
|
// does not for the direct file upload endpoint.
|
||||||
|
// THIS ALTERNATIVE METHOD IS LEFT AS-IS, ASSUMING A FUTURE BACKEND ENDPOINT
|
||||||
|
// OR A MODIFICATION TO THE EXISTING /import ENDPOINT TO ACCEPT JSON BODY.
|
||||||
const response = await apiClient.post<ApiResponse<any>>(
|
const response = await apiClient.post<ApiResponse<any>>(
|
||||||
'/api/v1/data/sales/import/json', // Need to add this endpoint to backend
|
`/api/v1/tenants/${tenantId}/sales/import/json`, // This endpoint does not exist in sales.py
|
||||||
importData
|
importData
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -312,7 +321,7 @@ export class DataService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateSalesData(file: File, tenantId?: string): Promise<DataValidation> {
|
async validateSalesData(file: File, tenantId: string): Promise<DataValidation> { // tenantId made required
|
||||||
try {
|
try {
|
||||||
console.log('Reading file content...', file.name);
|
console.log('Reading file content...', file.name);
|
||||||
|
|
||||||
@@ -342,7 +351,7 @@ export class DataService {
|
|||||||
|
|
||||||
// ✅ FIXED: Use proper tenant ID when available
|
// ✅ FIXED: Use proper tenant ID when available
|
||||||
const importData: SalesDataImportRequest = {
|
const importData: SalesDataImportRequest = {
|
||||||
tenant_id: tenantId || '00000000-0000-0000-0000-000000000000',
|
tenant_id: tenantId, // Use the provided tenantId
|
||||||
data: fileContent,
|
data: fileContent,
|
||||||
data_format: dataFormat,
|
data_format: dataFormat,
|
||||||
validate_only: true
|
validate_only: true
|
||||||
@@ -351,7 +360,7 @@ export class DataService {
|
|||||||
console.log('Sending validation request with tenant_id:', importData.tenant_id);
|
console.log('Sending validation request with tenant_id:', importData.tenant_id);
|
||||||
|
|
||||||
const response = await apiClient.post<ApiResponse<DataValidation>>(
|
const response = await apiClient.post<ApiResponse<DataValidation>>(
|
||||||
'/api/v1/data/sales/import/validate',
|
`/api/v1/tenants/${tenantId}/sales/import/validate`, // Correct endpoint with tenant_id
|
||||||
importData
|
importData
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -524,7 +533,7 @@ export class DataService {
|
|||||||
/**
|
/**
|
||||||
* Get sales records
|
* Get sales records
|
||||||
*/
|
*/
|
||||||
async getSalesRecords(params?: {
|
async getSalesRecords(tenantId: string, params?: { // Add tenantId
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
productName?: string;
|
productName?: string;
|
||||||
@@ -536,16 +545,16 @@ export class DataService {
|
|||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
}>>('/api/v1/data/sales', { params });
|
}>>(`/api/v1/tenants/${tenantId}/sales`, { params }); // Use tenantId in path
|
||||||
return response.data!;
|
return response.data!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create single sales record
|
* Create single sales record
|
||||||
*/
|
*/
|
||||||
async createSalesRecord(record: CreateSalesRequest): Promise<SalesRecord> {
|
async createSalesRecord(tenantId: string, record: CreateSalesRequest): Promise<SalesRecord> { // Add tenantId
|
||||||
const response = await apiClient.post<ApiResponse<SalesRecord>>(
|
const response = await apiClient.post<ApiResponse<SalesRecord>>(
|
||||||
'/api/v1/data/sales',
|
`/api/v1/tenants/${tenantId}/sales`, // Use tenantId in path
|
||||||
record
|
record
|
||||||
);
|
);
|
||||||
return response.data!;
|
return response.data!;
|
||||||
@@ -554,9 +563,9 @@ export class DataService {
|
|||||||
/**
|
/**
|
||||||
* Update sales record
|
* Update sales record
|
||||||
*/
|
*/
|
||||||
async updateSalesRecord(id: string, record: Partial<CreateSalesRequest>): Promise<SalesRecord> {
|
async updateSalesRecord(tenantId: string, id: string, record: Partial<CreateSalesRequest>): Promise<SalesRecord> { // Add tenantId
|
||||||
const response = await apiClient.put<ApiResponse<SalesRecord>>(
|
const response = await apiClient.put<ApiResponse<SalesRecord>>(
|
||||||
`/api/v1/data/sales/${id}`,
|
`/api/v1/tenants/${tenantId}/sales/${id}`, // Use tenantId in path
|
||||||
record
|
record
|
||||||
);
|
);
|
||||||
return response.data!;
|
return response.data!;
|
||||||
@@ -565,8 +574,8 @@ export class DataService {
|
|||||||
/**
|
/**
|
||||||
* Delete sales record
|
* Delete sales record
|
||||||
*/
|
*/
|
||||||
async deleteSalesRecord(id: string): Promise<void> {
|
async deleteSalesRecord(tenantId: string, id: string): Promise<void> { // Add tenantId
|
||||||
await apiClient.delete(`/api/v1/data/sales/${id}`);
|
await apiClient.delete(`/api/v1/tenants/${tenantId}/sales/${id}`); // Use tenantId in path
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -142,303 +142,8 @@ export class TenantService {
|
|||||||
* Corresponds to GET /users/{user_id}/tenants
|
* Corresponds to GET /users/{user_id}/tenants
|
||||||
*/
|
*/
|
||||||
async getUserTenants(userId: string): Promise<TenantInfo[]> {
|
async getUserTenants(userId: string): Promise<TenantInfo[]> {
|
||||||
const response = await apiClient.get<ApiResponse<TenantInfo[]>>(`/api/v1/users/${userId}/tenants`);
|
const response = await apiClient.get<ApiResponse<TenantInfo[]>>(`/api/v1/tenants/user/${userId}`);
|
||||||
return response.data!;
|
return response.data!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a team member to a tenant
|
|
||||||
* Corresponds to POST /tenants/{tenant_id}/members
|
|
||||||
*/
|
|
||||||
async addTeamMember(tenantId: string, userId: string, role: string): Promise<TenantMemberResponse> {
|
|
||||||
const response = await apiClient.post<ApiResponse<TenantMemberResponse>>(
|
|
||||||
`/api/v1/tenants/${tenantId}/members`,
|
|
||||||
{ user_id: userId, role }
|
|
||||||
);
|
|
||||||
return response.data!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Existing methods (kept for completeness, assuming they map to other backend endpoints not provided) ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current tenant info (no direct backend mapping in provided file, but common)
|
|
||||||
*/
|
|
||||||
async getCurrentTenant(): Promise<TenantInfo> {
|
|
||||||
const response = await apiClient.get<ApiResponse<TenantInfo>>('/api/v1/tenants/current');
|
|
||||||
return response.data!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update current tenant (no direct backend mapping in provided file, but common)
|
|
||||||
*/
|
|
||||||
async updateCurrentTenant(updates: TenantUpdate): Promise<TenantInfo> {
|
|
||||||
const response = await apiClient.put<ApiResponse<TenantInfo>>('/api/v1/tenants/current', updates);
|
|
||||||
return response.data!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tenant settings (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async getTenantSettings(): Promise<TenantSettings> {
|
|
||||||
const response = await apiClient.get<ApiResponse<TenantSettings>>('/api/v1/tenants/settings');
|
|
||||||
return response.data!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update tenant settings (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async updateTenantSettings(settings: Partial<TenantSettings>): Promise<TenantSettings> {
|
|
||||||
const response = await apiClient.put<ApiResponse<TenantSettings>>(
|
|
||||||
'/api/v1/tenants/settings',
|
|
||||||
settings
|
|
||||||
);
|
|
||||||
return response.data!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tenant statistics (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async getTenantStats(): Promise<TenantStats> {
|
|
||||||
const response = await apiClient.get<ApiResponse<TenantStats>>('/api/v1/tenants/stats');
|
|
||||||
return response.data!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tenant users (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async getTenantUsers(params?: {
|
|
||||||
role?: string;
|
|
||||||
active?: boolean;
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
}): Promise<{
|
|
||||||
users: TenantUser[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pages: number;
|
|
||||||
}> {
|
|
||||||
const response = await apiClient.get<ApiResponse<any>>('/api/v1/tenants/users', { params });
|
|
||||||
return response.data!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invite user to tenant (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async inviteUser(invitation: InviteUser): Promise<{
|
|
||||||
invitation_id: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
expires_at: string;
|
|
||||||
invitation_token: string;
|
|
||||||
}> {
|
|
||||||
const response = await apiClient.post<ApiResponse<any>>('/api/v1/tenants/users/invite', invitation);
|
|
||||||
return response.data!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user role (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async updateUserRole(userId: string, role: string): Promise<TenantUser> {
|
|
||||||
const response = await apiClient.patch<ApiResponse<TenantUser>>(
|
|
||||||
`/api/v1/tenants/users/${userId}`,
|
|
||||||
{ role }
|
|
||||||
);
|
|
||||||
return response.data!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deactivate user (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async deactivateUser(userId: string): Promise<TenantUser> {
|
|
||||||
const response = await apiClient.patch<ApiResponse<TenantUser>>(
|
|
||||||
`/api/v1/tenants/users/${userId}`,
|
|
||||||
{ is_active: false }
|
|
||||||
);
|
|
||||||
return response.data!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reactivate user (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async reactivateUser(userId: string): Promise<TenantUser> {
|
|
||||||
const response = await apiClient.patch<ApiResponse<TenantUser>>(
|
|
||||||
`/api/v1/tenants/users/${userId}`,
|
|
||||||
{ is_active: true }
|
|
||||||
);
|
|
||||||
return response.data!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove user from tenant (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async removeUser(userId: string): Promise<void> {
|
|
||||||
await apiClient.delete(`/api/v1/tenants/users/${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get pending invitations (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async getPendingInvitations(): Promise<{
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
invited_at: string;
|
|
||||||
expires_at: string;
|
|
||||||
invited_by: string;
|
|
||||||
}[]> {
|
|
||||||
const response = await apiClient.get<ApiResponse<any>>('/api/v1/tenants/invitations');
|
|
||||||
return response.data!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel invitation (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async cancelInvitation(invitationId: string): Promise<void> {
|
|
||||||
await apiClient.delete(`/api/v1/tenants/invitations/${invitationId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resend invitation (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async resendInvitation(invitationId: string): Promise<void> {
|
|
||||||
await apiClient.post(`/api/v1/tenants/invitations/${invitationId}/resend`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tenant activity log (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async getActivityLog(params?: {
|
|
||||||
userId?: string;
|
|
||||||
action?: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
}): Promise<{
|
|
||||||
activities: {
|
|
||||||
id: string;
|
|
||||||
user_id: string;
|
|
||||||
user_name: string;
|
|
||||||
action: string;
|
|
||||||
resource: string;
|
|
||||||
resource_id: string;
|
|
||||||
details?: Record<string, any>;
|
|
||||||
ip_address?: string;
|
|
||||||
user_agent?: string;
|
|
||||||
created_at: string;
|
|
||||||
}[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pages: number;
|
|
||||||
}> {
|
|
||||||
const response = await apiClient.get<ApiResponse<any>>('/api/v1/tenants/activity', { params });
|
|
||||||
return response.data!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tenant billing info (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async getBillingInfo(): Promise<{
|
|
||||||
subscription_plan: string;
|
|
||||||
billing_cycle: 'monthly' | 'yearly';
|
|
||||||
next_billing_date: string;
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
payment_method: {
|
|
||||||
type: string;
|
|
||||||
last_four: string;
|
|
||||||
expires: string;
|
|
||||||
};
|
|
||||||
usage: {
|
|
||||||
api_calls: number;
|
|
||||||
storage_mb: number;
|
|
||||||
users: number;
|
|
||||||
limits: {
|
|
||||||
api_calls_per_month: number;
|
|
||||||
storage_mb: number;
|
|
||||||
max_users: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}> {
|
|
||||||
const response = await apiClient.get<ApiResponse<any>>('/api/v1/tenants/billing');
|
|
||||||
return response.data!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update billing info (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async updateBillingInfo(billingData: {
|
|
||||||
payment_method_token?: string;
|
|
||||||
billing_address?: {
|
|
||||||
street: string;
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
zip: string;
|
|
||||||
country: string;
|
|
||||||
};
|
|
||||||
}): Promise<void> {
|
|
||||||
await apiClient.put('/api/v1/tenants/billing', billingData);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change subscription plan (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async changeSubscriptionPlan(
|
|
||||||
planId: string,
|
|
||||||
billingCycle: 'monthly' | 'yearly'
|
|
||||||
): Promise<{
|
|
||||||
subscription_id: string;
|
|
||||||
plan: string;
|
|
||||||
billing_cycle: string;
|
|
||||||
next_billing_date: string;
|
|
||||||
proration_amount?: number;
|
|
||||||
}> {
|
|
||||||
const response = await apiClient.post<ApiResponse<any>>('/api/v1/tenants/subscription/change', {
|
|
||||||
plan_id: planId,
|
|
||||||
billing_cycle: billingCycle,
|
|
||||||
});
|
|
||||||
return response.data!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel subscription (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async cancelSubscription(cancelAt: 'immediately' | 'end_of_period'): Promise<{
|
|
||||||
cancelled_at: string;
|
|
||||||
will_cancel_at: string;
|
|
||||||
refund_amount?: number;
|
|
||||||
}> {
|
|
||||||
const response = await apiClient.post<ApiResponse<any>>('/api/v1/tenants/subscription/cancel', {
|
|
||||||
cancel_at: cancelAt,
|
|
||||||
});
|
|
||||||
return response.data!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export tenant data (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async exportTenantData(dataTypes: string[], format: 'json' | 'csv'): Promise<Blob> {
|
|
||||||
const response = await apiClient.post('/api/v1/tenants/export', {
|
|
||||||
data_types: dataTypes,
|
|
||||||
format,
|
|
||||||
responseType: 'blob',
|
|
||||||
});
|
|
||||||
return response as unknown as Blob;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete tenant (GDPR compliance) (no direct backend mapping in provided file)
|
|
||||||
*/
|
|
||||||
async deleteTenant(confirmationToken: string): Promise<{
|
|
||||||
deletion_scheduled_at: string;
|
|
||||||
data_retention_until: string;
|
|
||||||
recovery_period_days: number;
|
|
||||||
}> {
|
|
||||||
const response = await apiClient.delete<ApiResponse<any>>('/api/v1/tenants/current', {
|
|
||||||
data: { confirmation_token: confirmationToken },
|
|
||||||
});
|
|
||||||
return response.data!;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tenantService = new TenantService();
|
|
||||||
@@ -75,9 +75,9 @@ export class TrainingService {
|
|||||||
/**
|
/**
|
||||||
* Start new training job
|
* Start new training job
|
||||||
*/
|
*/
|
||||||
async startTraining(config: TrainingConfiguration): Promise<TrainingJobStatus> {
|
async startTraining(tenantId: string, config: TrainingConfiguration): Promise<TrainingJobStatus> {
|
||||||
const response = await apiClient.post<TrainingJobStatus>(
|
const response = await apiClient.post<TrainingJobStatus>(
|
||||||
'/api/v1/training/jobs',
|
`/api/v1/tenants/${tenantId}/training/jobs`,
|
||||||
config
|
config
|
||||||
);
|
);
|
||||||
return response.data!;
|
return response.data!;
|
||||||
@@ -86,9 +86,9 @@ export class TrainingService {
|
|||||||
/**
|
/**
|
||||||
* Get training job status
|
* Get training job status
|
||||||
*/
|
*/
|
||||||
async getTrainingStatus(jobId: string): Promise<TrainingJobProgress> {
|
async getTrainingStatus(tenantId: string, jobId: string): Promise<TrainingJobProgress> {
|
||||||
const response = await apiClient.get<ApiResponse<TrainingJobProgress>>(
|
const response = await apiClient.get<ApiResponse<TrainingJobProgress>>(
|
||||||
`/api/v1/training/jobs/${jobId}`
|
`/api/v1/tenants/${tenantId}/training/jobs/${jobId}`
|
||||||
);
|
);
|
||||||
return response.data!;
|
return response.data!;
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ export class TrainingService {
|
|||||||
/**
|
/**
|
||||||
* Get all training jobs
|
* Get all training jobs
|
||||||
*/
|
*/
|
||||||
async getTrainingHistory(params?: {
|
async getTrainingHistory(tenantId: string, params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -113,14 +113,14 @@ export class TrainingService {
|
|||||||
/**
|
/**
|
||||||
* Cancel training job
|
* Cancel training job
|
||||||
*/
|
*/
|
||||||
async cancelTraining(jobId: string): Promise<void> {
|
async cancelTraining(tenantId: string, jobId: string): Promise<void> {
|
||||||
await apiClient.post(`/api/v1/training/jobs/${jobId}/cancel`);
|
await apiClient.post(`/api/v1/tenants/${tenantId}/training/jobs/${jobId}/cancel`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get trained models
|
* Get trained models
|
||||||
*/
|
*/
|
||||||
async getModels(params?: {
|
async getModels(tenantId: string, params?: {
|
||||||
productName?: string;
|
productName?: string;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -131,14 +131,14 @@ export class TrainingService {
|
|||||||
page: number;
|
page: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
}> {
|
}> {
|
||||||
const response = await apiClient.get<ApiResponse<any>>('/api/v1/training/models', { params });
|
const response = await apiClient.get<ApiResponse<any>>(`/api/v1/tenants/${tenantId}/training/models`, { params });
|
||||||
return response.data!;
|
return response.data!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get specific model details
|
* Get specific model details
|
||||||
*/
|
*/
|
||||||
async getModel(modelId: string): Promise<TrainedModel> {
|
async getModel(tenantId: string, modelId: string): Promise<TrainedModel> {
|
||||||
const response = await apiClient.get<ApiResponse<TrainedModel>>(
|
const response = await apiClient.get<ApiResponse<TrainedModel>>(
|
||||||
`/api/v1/training/models/${modelId}`
|
`/api/v1/training/models/${modelId}`
|
||||||
);
|
);
|
||||||
@@ -148,9 +148,9 @@ export class TrainingService {
|
|||||||
/**
|
/**
|
||||||
* Get model metrics
|
* Get model metrics
|
||||||
*/
|
*/
|
||||||
async getModelMetrics(modelId: string): Promise<ModelMetrics> {
|
async getModelMetrics(tenantId: string, modelId: string): Promise<ModelMetrics> {
|
||||||
const response = await apiClient.get<ApiResponse<ModelMetrics>>(
|
const response = await apiClient.get<ApiResponse<ModelMetrics>>(
|
||||||
`/api/v1/training/models/${modelId}/metrics`
|
`/api/v1/tenants/${tenantId}/training/models/${modelId}/metrics`
|
||||||
);
|
);
|
||||||
return response.data!;
|
return response.data!;
|
||||||
}
|
}
|
||||||
@@ -158,9 +158,9 @@ export class TrainingService {
|
|||||||
/**
|
/**
|
||||||
* Activate/deactivate model
|
* Activate/deactivate model
|
||||||
*/
|
*/
|
||||||
async toggleModelStatus(modelId: string, active: boolean): Promise<TrainedModel> {
|
async toggleModelStatus(tenantId: string, modelId: string, active: boolean): Promise<TrainedModel> {
|
||||||
const response = await apiClient.patch<ApiResponse<TrainedModel>>(
|
const response = await apiClient.patch<ApiResponse<TrainedModel>>(
|
||||||
`/api/v1/training/models/${modelId}`,
|
`/api/v1/tenants/${tenantId}/training/models/${modelId}`,
|
||||||
{ is_active: active }
|
{ is_active: active }
|
||||||
);
|
);
|
||||||
return response.data!;
|
return response.data!;
|
||||||
@@ -169,16 +169,16 @@ export class TrainingService {
|
|||||||
/**
|
/**
|
||||||
* Delete model
|
* Delete model
|
||||||
*/
|
*/
|
||||||
async deleteModel(modelId: string): Promise<void> {
|
async deleteModel(tenantId: string, modelId: string): Promise<void> {
|
||||||
await apiClient.delete(`/api/v1/training/models/${modelId}`);
|
await apiClient.delete(`/api/v1/training/models/${modelId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Train specific product
|
* Train specific product
|
||||||
*/
|
*/
|
||||||
async trainProduct(productName: string, config?: Partial<TrainingConfiguration>): Promise<TrainingJobStatus> {
|
async trainProduct(tenantId: string, productName: string, config?: Partial<TrainingConfiguration>): Promise<TrainingJobStatus> {
|
||||||
const response = await apiClient.post<ApiResponse<TrainingJobStatus>>(
|
const response = await apiClient.post<ApiResponse<TrainingJobStatus>>(
|
||||||
'/api/v1/training/products/train',
|
`/api/v1/tenants/${tenantId}/training/products/train`,
|
||||||
{
|
{
|
||||||
product_name: productName,
|
product_name: productName,
|
||||||
...config,
|
...config,
|
||||||
@@ -190,7 +190,7 @@ export class TrainingService {
|
|||||||
/**
|
/**
|
||||||
* Get training statistics
|
* Get training statistics
|
||||||
*/
|
*/
|
||||||
async getTrainingStats(): Promise<{
|
async getTrainingStats(tenantId: string): Promise<{
|
||||||
total_models: number;
|
total_models: number;
|
||||||
active_models: number;
|
active_models: number;
|
||||||
avg_accuracy: number;
|
avg_accuracy: number;
|
||||||
@@ -198,21 +198,21 @@ export class TrainingService {
|
|||||||
products_trained: number;
|
products_trained: number;
|
||||||
training_time_avg_minutes: number;
|
training_time_avg_minutes: number;
|
||||||
}> {
|
}> {
|
||||||
const response = await apiClient.get<ApiResponse<any>>('/api/v1/training/stats');
|
const response = await apiClient.get<ApiResponse<any>>(`/api/v1/tenants/${tenantId}/training/stats`);
|
||||||
return response.data!;
|
return response.data!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate training data
|
* Validate training data
|
||||||
*/
|
*/
|
||||||
async validateTrainingData(products?: string[]): Promise<{
|
async validateTrainingData(tenantId: string, products?: string[]): Promise<{
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
product_data_points: Record<string, number>;
|
product_data_points: Record<string, number>;
|
||||||
recommendation: string;
|
recommendation: string;
|
||||||
}> {
|
}> {
|
||||||
const response = await apiClient.post<ApiResponse<any>>('/api/v1/training/validate', {
|
const response = await apiClient.post<ApiResponse<any>>(`/api/v1/tenants/${tenantId}/training/validate`, {
|
||||||
products,
|
products,
|
||||||
});
|
});
|
||||||
return response.data!;
|
return response.data!;
|
||||||
@@ -221,29 +221,29 @@ export class TrainingService {
|
|||||||
/**
|
/**
|
||||||
* Get training recommendations
|
* Get training recommendations
|
||||||
*/
|
*/
|
||||||
async getTrainingRecommendations(): Promise<{
|
async getTrainingRecommendations(tenantId: string): Promise<{
|
||||||
should_retrain: boolean;
|
should_retrain: boolean;
|
||||||
reasons: string[];
|
reasons: string[];
|
||||||
recommended_products: string[];
|
recommended_products: string[];
|
||||||
optimal_config: TrainingConfiguration;
|
optimal_config: TrainingConfiguration;
|
||||||
}> {
|
}> {
|
||||||
const response = await apiClient.get<ApiResponse<any>>('/api/v1/training/recommendations');
|
const response = await apiClient.get<ApiResponse<any>>(`/api/v1/tenants/${tenantId}/training/recommendations`);
|
||||||
return response.data!;
|
return response.data!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get training logs
|
* Get training logs
|
||||||
*/
|
*/
|
||||||
async getTrainingLogs(jobId: string): Promise<string[]> {
|
async getTrainingLogs(tenantId: string, jobId: string): Promise<string[]> {
|
||||||
const response = await apiClient.get<ApiResponse<string[]>>(`/api/v1/training/jobs/${jobId}/logs`);
|
const response = await apiClient.get<ApiResponse<string[]>>(`/api/v1/tenants/${tenantId}/training/jobs/${jobId}/logs`);
|
||||||
return response.data!;
|
return response.data!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export model
|
* Export model
|
||||||
*/
|
*/
|
||||||
async exportModel(modelId: string, format: 'pickle' | 'onnx' = 'pickle'): Promise<Blob> {
|
async exportModel(tenantId: string, modelId: string, format: 'pickle' | 'onnx' = 'pickle'): Promise<Blob> {
|
||||||
const response = await apiClient.get(`/api/v1/training/models/${modelId}/export`, {
|
const response = await apiClient.get(`/api/v1/tenants/${tenantId}/training/models/${modelId}/export`, {
|
||||||
params: { format },
|
params: { format },
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ const OnboardingPage = () => {
|
|||||||
console.log('Starting training with config:', trainingConfig);
|
console.log('Starting training with config:', trainingConfig);
|
||||||
|
|
||||||
// Start training via API
|
// Start training via API
|
||||||
const trainingJob: TrainingJobStatus = await api.training.startTraining(trainingConfig);
|
const trainingJob: TrainingJobStatus = await api.training.startTraining(currentTenantId, trainingConfig);
|
||||||
|
|
||||||
// Update form data with training job ID
|
// Update form data with training job ID
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
@@ -326,7 +326,7 @@ const OnboardingPage = () => {
|
|||||||
hyperparameter_tuning: true
|
hyperparameter_tuning: true
|
||||||
};
|
};
|
||||||
|
|
||||||
const trainingJob = await api.training.startTraining(trainingConfig);
|
const trainingJob = await api.training.startTraining(currentTenantId, trainingConfig);
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
trainingTaskId: trainingJob.id,
|
trainingTaskId: trainingJob.id,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from app.core.service_discovery import ServiceDiscovery
|
|||||||
from app.middleware.auth import AuthMiddleware
|
from app.middleware.auth import AuthMiddleware
|
||||||
from app.middleware.logging import LoggingMiddleware
|
from app.middleware.logging import LoggingMiddleware
|
||||||
from app.middleware.rate_limit import RateLimitMiddleware
|
from app.middleware.rate_limit import RateLimitMiddleware
|
||||||
from app.routes import auth, training, forecasting, data, tenant, notification, nominatim, user
|
from app.routes import auth, tenant, notification, nominatim, user
|
||||||
from shared.monitoring.logging import setup_logging
|
from shared.monitoring.logging import setup_logging
|
||||||
from shared.monitoring.metrics import MetricsCollector
|
from shared.monitoring.metrics import MetricsCollector
|
||||||
|
|
||||||
@@ -56,10 +56,7 @@ app.add_middleware(AuthMiddleware)
|
|||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["authentication"])
|
app.include_router(auth.router, prefix="/api/v1/auth", tags=["authentication"])
|
||||||
app.include_router(auth.router, prefix="/api/v1/user", tags=["user"])
|
app.include_router(user.router, prefix="/api/v1/user", tags=["user"])
|
||||||
app.include_router(training.router, prefix="/api/v1/training", tags=["training"])
|
|
||||||
app.include_router(forecasting.router, prefix="/api/v1/forecasting", tags=["forecasting"])
|
|
||||||
app.include_router(data.router, prefix="/api/v1/data", tags=["data"])
|
|
||||||
app.include_router(tenant.router, prefix="/api/v1/tenants", tags=["tenants"])
|
app.include_router(tenant.router, prefix="/api/v1/tenants", tags=["tenants"])
|
||||||
app.include_router(notification.router, prefix="/api/v1/notifications", tags=["notifications"])
|
app.include_router(notification.router, prefix="/api/v1/notifications", tags=["notifications"])
|
||||||
app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"])
|
app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"])
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# gateway/app/middleware/auth.py - IMPROVED VERSION
|
# gateway/app/middleware/auth.py
|
||||||
"""
|
"""
|
||||||
Enhanced Authentication Middleware for API Gateway
|
Enhanced Authentication Middleware for API Gateway with Tenant Access Control
|
||||||
Implements proper token validation and tenant context extraction
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
@@ -9,12 +8,11 @@ from fastapi import Request, HTTPException
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
import httpx
|
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from shared.auth.jwt_handler import JWTHandler
|
from shared.auth.jwt_handler import JWTHandler
|
||||||
|
from shared.auth.tenant_access import tenant_access_manager, extract_tenant_id_from_path, is_tenant_scoped_path
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
@@ -32,20 +30,12 @@ PUBLIC_ROUTES = [
|
|||||||
"/api/v1/auth/register",
|
"/api/v1/auth/register",
|
||||||
"/api/v1/auth/refresh",
|
"/api/v1/auth/refresh",
|
||||||
"/api/v1/auth/verify",
|
"/api/v1/auth/verify",
|
||||||
"/api/v1/tenant/register",
|
|
||||||
"/api/v1/nominatim/search"
|
"/api/v1/nominatim/search"
|
||||||
]
|
]
|
||||||
|
|
||||||
class AuthMiddleware(BaseHTTPMiddleware):
|
class AuthMiddleware(BaseHTTPMiddleware):
|
||||||
"""
|
"""
|
||||||
Enhanced Authentication Middleware following microservices best practices
|
Enhanced Authentication Middleware with Tenant Access Control
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
1. Token validation (local first, then auth service)
|
|
||||||
2. User context injection
|
|
||||||
3. Tenant context extraction (per request)
|
|
||||||
4. Rate limiting enforcement
|
|
||||||
5. Request routing decisions
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, app, redis_client=None):
|
def __init__(self, app, redis_client=None):
|
||||||
@@ -53,7 +43,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
self.redis_client = redis_client # For caching and rate limiting
|
self.redis_client = redis_client # For caching and rate limiting
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next) -> Response:
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
"""Process request with enhanced authentication"""
|
"""Process request with enhanced authentication and tenant access control"""
|
||||||
|
|
||||||
# Skip authentication for OPTIONS requests (CORS preflight)
|
# Skip authentication for OPTIONS requests (CORS preflight)
|
||||||
if request.method == "OPTIONS":
|
if request.method == "OPTIONS":
|
||||||
@@ -63,7 +53,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
if self._is_public_route(request.url.path):
|
if self._is_public_route(request.url.path):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
# Extract and validate JWT token
|
# ✅ STEP 1: Extract and validate JWT token
|
||||||
token = self._extract_token(request)
|
token = self._extract_token(request)
|
||||||
if not token:
|
if not token:
|
||||||
logger.warning(f"Missing token for protected route: {request.url.path}")
|
logger.warning(f"Missing token for protected route: {request.url.path}")
|
||||||
@@ -72,7 +62,8 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
content={"detail": "Authentication required"}
|
content={"detail": "Authentication required"}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify token and get user context
|
# ✅ STEP 2: Verify token and get user context
|
||||||
|
# Pass self.redis_client to _verify_token to enable caching
|
||||||
user_context = await self._verify_token(token)
|
user_context = await self._verify_token(token)
|
||||||
if not user_context:
|
if not user_context:
|
||||||
logger.warning(f"Invalid token for route: {request.url.path}")
|
logger.warning(f"Invalid token for route: {request.url.path}")
|
||||||
@@ -81,28 +72,48 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
content={"detail": "Invalid or expired token"}
|
content={"detail": "Invalid or expired token"}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract tenant context from request (not from JWT)
|
# ✅ STEP 3: Extract tenant context from URL using shared utility
|
||||||
tenant_id = self._extract_tenant_from_request(request)
|
tenant_id = extract_tenant_id_from_path(request.url.path)
|
||||||
|
|
||||||
|
# ✅ STEP 4: Verify tenant access if this is a tenant-scoped route
|
||||||
|
if tenant_id and is_tenant_scoped_path(request.url.path):
|
||||||
|
# Use TenantAccessManager for gateway-level verification with caching
|
||||||
|
# Ensure tenant_access_manager uses the redis_client from the middleware
|
||||||
|
if self.redis_client and tenant_access_manager.redis_client is None:
|
||||||
|
tenant_access_manager.redis_client = self.redis_client
|
||||||
|
|
||||||
|
has_access = await tenant_access_manager.verify_basic_tenant_access( # Corrected method call
|
||||||
|
user_context["user_id"],
|
||||||
|
tenant_id
|
||||||
|
)
|
||||||
|
|
||||||
# Verify user has access to tenant (if tenant_id provided)
|
|
||||||
if tenant_id:
|
|
||||||
has_access = await self._verify_tenant_access(user_context["user_id"], tenant_id)
|
|
||||||
if not has_access:
|
if not has_access:
|
||||||
logger.warning(f"User {user_context['email']} denied access to tenant {tenant_id}")
|
logger.warning(f"User {user_context['email']} denied access to tenant {tenant_id}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
content={"detail": "Access denied to tenant"}
|
content={"detail": f"Access denied to tenant {tenant_id}"}
|
||||||
)
|
)
|
||||||
request.state.tenant_id = tenant_id
|
|
||||||
|
|
||||||
# Inject user context into request
|
# Set tenant context in request state
|
||||||
|
request.state.tenant_id = tenant_id
|
||||||
|
request.state.tenant_verified = True
|
||||||
|
|
||||||
|
logger.debug(f"Tenant access verified",
|
||||||
|
user_id=user_context["user_id"],
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
path=request.url.path)
|
||||||
|
|
||||||
|
# ✅ STEP 5: Inject user context into request
|
||||||
request.state.user = user_context
|
request.state.user = user_context
|
||||||
request.state.authenticated = True
|
request.state.authenticated = True
|
||||||
|
|
||||||
# Add user context to forwarded requests
|
# ✅ STEP 6: Add context headers for downstream services
|
||||||
self._inject_auth_headers(request, user_context, tenant_id)
|
self._inject_context_headers(request, user_context, tenant_id)
|
||||||
|
|
||||||
logger.debug(f"Authenticated request: {user_context['email']} -> {request.url.path}")
|
logger.debug(f"Authenticated request",
|
||||||
|
user_email=user_context['email'],
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
path=request.url.path)
|
||||||
|
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
@@ -117,46 +128,10 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
return auth_header.split(" ")[1]
|
return auth_header.split(" ")[1]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _extract_tenant_from_request(self, request: Request) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Extract tenant ID from request (NOT from JWT token)
|
|
||||||
|
|
||||||
Priority order:
|
|
||||||
1. X-Tenant-ID header
|
|
||||||
2. tenant_id query parameter
|
|
||||||
3. tenant_id in request path
|
|
||||||
"""
|
|
||||||
# Method 1: Header
|
|
||||||
tenant_id = request.headers.get("X-Tenant-ID")
|
|
||||||
if tenant_id:
|
|
||||||
return tenant_id
|
|
||||||
|
|
||||||
# Method 2: Query parameter
|
|
||||||
tenant_id = request.query_params.get("tenant_id")
|
|
||||||
if tenant_id:
|
|
||||||
return tenant_id
|
|
||||||
|
|
||||||
# Method 3: Path parameter (extract from URLs like /api/v1/tenants/{tenant_id}/...)
|
|
||||||
path_parts = request.url.path.split("/")
|
|
||||||
if "tenants" in path_parts:
|
|
||||||
try:
|
|
||||||
tenant_index = path_parts.index("tenants")
|
|
||||||
if tenant_index + 1 < len(path_parts):
|
|
||||||
return path_parts[tenant_index + 1]
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _verify_token(self, token: str) -> Optional[Dict[str, Any]]:
|
async def _verify_token(self, token: str) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""Verify JWT token with fallback strategy"""
|
||||||
Verify JWT token with fallback strategy:
|
|
||||||
1. Local validation (fast)
|
|
||||||
2. Auth service validation (authoritative)
|
|
||||||
3. Cache valid tokens to reduce auth service calls
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Step 1: Try local JWT validation first (fast)
|
# Try local JWT validation first (fast)
|
||||||
try:
|
try:
|
||||||
payload = jwt_handler.verify_token(token)
|
payload = jwt_handler.verify_token(token)
|
||||||
if payload and self._validate_token_payload(payload):
|
if payload and self._validate_token_payload(payload):
|
||||||
@@ -165,7 +140,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Local token validation failed: {e}")
|
logger.debug(f"Local token validation failed: {e}")
|
||||||
|
|
||||||
# Step 2: Check cache for recently validated tokens
|
# Check cache for recently validated tokens
|
||||||
if self.redis_client:
|
if self.redis_client:
|
||||||
try:
|
try:
|
||||||
cached_user = await self._get_cached_user(token)
|
cached_user = await self._get_cached_user(token)
|
||||||
@@ -175,7 +150,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Cache lookup failed: {e}")
|
logger.warning(f"Cache lookup failed: {e}")
|
||||||
|
|
||||||
# Step 3: Verify with auth service (authoritative)
|
# Verify with auth service (authoritative)
|
||||||
try:
|
try:
|
||||||
user_context = await self._verify_with_auth_service(token)
|
user_context = await self._verify_with_auth_service(token)
|
||||||
if user_context:
|
if user_context:
|
||||||
@@ -197,6 +172,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
async def _verify_with_auth_service(self, token: str) -> Optional[Dict[str, Any]]:
|
async def _verify_with_auth_service(self, token: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Verify token with auth service"""
|
"""Verify token with auth service"""
|
||||||
try:
|
try:
|
||||||
|
import httpx
|
||||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{settings.AUTH_SERVICE_URL}/api/v1/auth/verify",
|
f"{settings.AUTH_SERVICE_URL}/api/v1/auth/verify",
|
||||||
@@ -209,35 +185,25 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
logger.warning(f"Auth service returned {response.status_code}")
|
logger.warning(f"Auth service returned {response.status_code}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.error("Auth service timeout")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Auth service error: {e}")
|
logger.error(f"Auth service error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _verify_tenant_access(self, user_id: str, tenant_id: str) -> bool:
|
|
||||||
"""Verify user has access to specific tenant"""
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
|
||||||
response = await client.get(
|
|
||||||
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/access/{user_id}"
|
|
||||||
)
|
|
||||||
return response.status_code == 200
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Tenant access verification failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _get_cached_user(self, token: str) -> Optional[Dict[str, Any]]:
|
async def _get_cached_user(self, token: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get user context from cache"""
|
"""Get user context from cache"""
|
||||||
if not self.redis_client:
|
if not self.redis_client:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
cache_key = f"auth:token:{hash(token)}"
|
cache_key = f"auth:token:{hash(token)}"
|
||||||
cached_data = await self.redis_client.get(cache_key)
|
try:
|
||||||
if cached_data:
|
cached_data = await self.redis_client.get(cache_key)
|
||||||
import json
|
if cached_data:
|
||||||
return json.loads(cached_data)
|
import json
|
||||||
|
if isinstance(cached_data, bytes):
|
||||||
|
cached_data = cached_data.decode()
|
||||||
|
return json.loads(cached_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Cache get failed: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _cache_user(self, token: str, user_context: Dict[str, Any], ttl: int = 300):
|
async def _cache_user(self, token: str, user_context: Dict[str, Any], ttl: int = 300):
|
||||||
@@ -246,45 +212,45 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
return
|
return
|
||||||
|
|
||||||
cache_key = f"auth:token:{hash(token)}"
|
cache_key = f"auth:token:{hash(token)}"
|
||||||
import json
|
try:
|
||||||
await self.redis_client.setex(cache_key, ttl, json.dumps(user_context))
|
import json
|
||||||
|
await self.redis_client.setex(cache_key, ttl, json.dumps(user_context))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Cache set failed: {e}")
|
||||||
|
|
||||||
def _inject_auth_headers(self, request: Request, user_context: Dict[str, Any], tenant_id: Optional[str]):
|
def _inject_context_headers(self, request: Request, user_context: Dict[str, Any], tenant_id: Optional[str]):
|
||||||
"""
|
"""Inject authentication and tenant headers for downstream services"""
|
||||||
Inject authentication headers for downstream services
|
|
||||||
|
|
||||||
This allows services to work both:
|
# Remove any existing auth headers to prevent spoofing
|
||||||
1. Behind the gateway (using request.state)
|
headers_to_remove = [
|
||||||
2. Called directly (using headers) for development/testing
|
"x-user-id", "x-user-email", "x-user-role",
|
||||||
"""
|
"x-tenant-id", "x-tenant-verified", "x-authenticated"
|
||||||
# Remove any existing auth headers to prevent spoofing
|
]
|
||||||
headers_to_remove = [
|
|
||||||
"x-user-id", "x-user-email", "x-user-role",
|
for header in headers_to_remove:
|
||||||
"x-tenant-id", "x-user-permissions", "x-authenticated"
|
request.headers.__dict__["_list"] = [
|
||||||
|
(k, v) for k, v in request.headers.raw
|
||||||
|
if k.lower() != header.lower()
|
||||||
]
|
]
|
||||||
|
|
||||||
for header in headers_to_remove:
|
# Inject new headers
|
||||||
request.headers.__dict__["_list"] = [
|
new_headers = [
|
||||||
(k, v) for k, v in request.headers.raw
|
(b"x-authenticated", b"true"),
|
||||||
if k.lower() != header.lower()
|
(b"x-user-id", str(user_context.get("user_id", "")).encode()),
|
||||||
]
|
(b"x-user-email", str(user_context.get("email", "")).encode()),
|
||||||
|
(b"x-user-role", str(user_context.get("role", "user")).encode()),
|
||||||
|
]
|
||||||
|
|
||||||
# Inject new headers
|
# Add tenant context if verified
|
||||||
new_headers = [
|
if tenant_id:
|
||||||
(b"x-authenticated", b"true"),
|
new_headers.extend([
|
||||||
(b"x-user-id", str(user_context.get("user_id", "")).encode()),
|
(b"x-tenant-id", tenant_id.encode()),
|
||||||
(b"x-user-email", str(user_context.get("email", "")).encode()),
|
(b"x-tenant-verified", b"true")
|
||||||
(b"x-user-role", str(user_context.get("role", "user")).encode()),
|
])
|
||||||
]
|
|
||||||
|
|
||||||
if tenant_id:
|
# Add headers to request
|
||||||
new_headers.append((b"x-tenant-id", tenant_id.encode()))
|
request.headers.__dict__["_list"].extend(new_headers)
|
||||||
|
|
||||||
permissions = user_context.get("permissions", [])
|
logger.debug(f"Injected context headers",
|
||||||
if permissions:
|
user_id=user_context.get("user_id"),
|
||||||
new_headers.append((b"x-user-permissions", ",".join(permissions).encode()))
|
tenant_id=tenant_id)
|
||||||
|
|
||||||
# Add headers to request
|
|
||||||
request.headers.__dict__["_list"].extend(new_headers)
|
|
||||||
|
|
||||||
logger.debug(f"Injected auth headers for user {user_context.get('email')}")
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"""Data service routes for API Gateway - Authentication handled by gateway middleware"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Response, HTTPException
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
import httpx
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.api_route("/sales/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
|
||||||
async def proxy_sales(request: Request, path: str):
|
|
||||||
"""Proxy sales data requests to data service"""
|
|
||||||
return await _proxy_request(request, f"/api/v1/sales/{path}")
|
|
||||||
|
|
||||||
@router.api_route("/weather/{path:path}", methods=["GET", "POST", "OPTIONS"])
|
|
||||||
async def proxy_weather(request: Request, path: str):
|
|
||||||
"""Proxy weather requests to data service"""
|
|
||||||
return await _proxy_request(request, f"/api/v1/weather/{path}")
|
|
||||||
|
|
||||||
@router.api_route("/traffic/{path:path}", methods=["GET", "POST", "OPTIONS"])
|
|
||||||
async def proxy_traffic(request: Request, path: str):
|
|
||||||
"""Proxy traffic requests to data service"""
|
|
||||||
return await _proxy_request(request, f"/api/v1/traffic/{path}")
|
|
||||||
|
|
||||||
async def _proxy_request(request: Request, target_path: str):
|
|
||||||
"""Proxy request to data service with user context"""
|
|
||||||
|
|
||||||
# Handle OPTIONS requests directly for CORS
|
|
||||||
if request.method == "OPTIONS":
|
|
||||||
return Response(
|
|
||||||
status_code=200,
|
|
||||||
headers={
|
|
||||||
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
|
|
||||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
||||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
|
|
||||||
"Access-Control-Allow-Credentials": "true",
|
|
||||||
"Access-Control-Max-Age": "86400" # Cache preflight for 24 hours
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
url = f"{settings.DATA_SERVICE_URL}{target_path}"
|
|
||||||
|
|
||||||
# Forward headers BUT add user context from gateway auth
|
|
||||||
headers = dict(request.headers)
|
|
||||||
headers.pop("host", None) # Remove host header
|
|
||||||
|
|
||||||
# ✅ ADD USER CONTEXT FROM GATEWAY AUTHENTICATION
|
|
||||||
# Gateway middleware already verified the token and added user to request.state
|
|
||||||
if hasattr(request.state, 'user'):
|
|
||||||
headers["X-User-ID"] = str(request.state.user.get("user_id"))
|
|
||||||
headers["X-User-Email"] = request.state.user.get("email", "")
|
|
||||||
headers["X-Tenant-ID"] = str(request.state.user.get("tenant_id"))
|
|
||||||
headers["X-User-Roles"] = ",".join(request.state.user.get("roles", []))
|
|
||||||
|
|
||||||
# Get request body if present
|
|
||||||
body = None
|
|
||||||
if request.method in ["POST", "PUT", "PATCH"]:
|
|
||||||
body = await request.body()
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
||||||
response = await client.request(
|
|
||||||
method=request.method,
|
|
||||||
url=url,
|
|
||||||
params=request.query_params,
|
|
||||||
headers=headers,
|
|
||||||
content=body
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return streaming response for large payloads
|
|
||||||
if int(response.headers.get("content-length", 0)) > 1024:
|
|
||||||
return StreamingResponse(
|
|
||||||
iter([response.content]),
|
|
||||||
status_code=response.status_code,
|
|
||||||
headers=dict(response.headers),
|
|
||||||
media_type=response.headers.get("content-type")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return response.json() if response.headers.get("content-type", "").startswith("application/json") else response.content
|
|
||||||
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
logger.error("Data service request failed", error=str(e))
|
|
||||||
raise HTTPException(status_code=503, detail="Data service unavailable")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Unexpected error in data proxy", error=str(e))
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# ================================================================
|
|
||||||
# Gateway Integration: Update gateway/app/routes/forecasting.py
|
|
||||||
# ================================================================
|
|
||||||
"""Forecasting service routes for API Gateway"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
import httpx
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.api_route("/forecasts/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
|
||||||
async def proxy_forecasts(request: Request, path: str):
|
|
||||||
"""Proxy forecast requests to forecasting service"""
|
|
||||||
return await _proxy_request(request, f"/api/v1/forecasts/{path}")
|
|
||||||
|
|
||||||
@router.api_route("/predictions/{path:path}", methods=["GET", "POST", "OPTIONS"])
|
|
||||||
async def proxy_predictions(request: Request, path: str):
|
|
||||||
"""Proxy prediction requests to forecasting service"""
|
|
||||||
return await _proxy_request(request, f"/api/v1/predictions/{path}")
|
|
||||||
|
|
||||||
async def _proxy_request(request: Request, target_path: str):
|
|
||||||
"""Proxy request to forecasting service with user context"""
|
|
||||||
|
|
||||||
# Handle OPTIONS requests directly for CORS
|
|
||||||
if request.method == "OPTIONS":
|
|
||||||
return Response(
|
|
||||||
status_code=200,
|
|
||||||
headers={
|
|
||||||
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
|
|
||||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
||||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
|
|
||||||
"Access-Control-Allow-Credentials": "true",
|
|
||||||
"Access-Control-Max-Age": "86400" # Cache preflight for 24 hours
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
url = f"{settings.FORECASTING_SERVICE_URL}{target_path}"
|
|
||||||
|
|
||||||
# Forward headers and add user context
|
|
||||||
headers = dict(request.headers)
|
|
||||||
headers.pop("host", None)
|
|
||||||
|
|
||||||
# Add user context from gateway authentication
|
|
||||||
if hasattr(request.state, 'user'):
|
|
||||||
headers["X-User-ID"] = str(request.state.user.get("user_id"))
|
|
||||||
headers["X-User-Email"] = request.state.user.get("email", "")
|
|
||||||
headers["X-Tenant-ID"] = str(request.state.user.get("tenant_id"))
|
|
||||||
headers["X-User-Roles"] = ",".join(request.state.user.get("roles", []))
|
|
||||||
|
|
||||||
# Get request body if present
|
|
||||||
body = None
|
|
||||||
if request.method in ["POST", "PUT", "PATCH"]:
|
|
||||||
body = await request.body()
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
||||||
response = await client.request(
|
|
||||||
method=request.method,
|
|
||||||
url=url,
|
|
||||||
headers=headers,
|
|
||||||
content=body,
|
|
||||||
params=request.query_params
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return response
|
|
||||||
return response.json() if response.headers.get("content-type", "").startswith("application/json") else response.text
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error proxying to forecasting service: {e}")
|
|
||||||
raise
|
|
||||||
@@ -1,156 +1,200 @@
|
|||||||
|
# gateway/app/routes/tenant.py - COMPLETELY UPDATED
|
||||||
"""
|
"""
|
||||||
Tenant routes for gateway - FIXED VERSION
|
Tenant routes for API Gateway - Handles all tenant-scoped endpoints
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, HTTPException
|
from fastapi import APIRouter, Request, Response, HTTPException, Path
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
import httpx
|
import httpx
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# TENANT MANAGEMENT ENDPOINTS
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
@router.post("/register")
|
@router.post("/register")
|
||||||
async def create_tenant(request: Request):
|
async def create_tenant(request: Request):
|
||||||
"""Proxy tenant creation to tenant service"""
|
"""Proxy tenant creation to tenant service"""
|
||||||
try:
|
return await _proxy_to_tenant_service(request, "/api/v1/tenants/register")
|
||||||
body = await request.body()
|
|
||||||
|
|
||||||
# ✅ FIX: Forward all headers AND add user context from gateway auth
|
@router.get("/{tenant_id}")
|
||||||
headers = dict(request.headers)
|
async def get_tenant(request: Request, tenant_id: str = Path(...)):
|
||||||
headers.pop("host", None) # Remove host header
|
"""Get specific tenant details"""
|
||||||
|
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}")
|
||||||
|
|
||||||
# ✅ ADD USER CONTEXT FROM GATEWAY AUTHENTICATION
|
@router.put("/{tenant_id}")
|
||||||
# Gateway middleware already verified the token and added user to request.state
|
async def update_tenant(request: Request, tenant_id: str = Path(...)):
|
||||||
if hasattr(request.state, 'user'):
|
"""Update tenant details"""
|
||||||
headers["X-User-ID"] = str(request.state.user.get("user_id"))
|
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}")
|
||||||
headers["X-User-Email"] = request.state.user.get("email", "")
|
|
||||||
headers["X-User-Role"] = request.state.user.get("role", "user")
|
|
||||||
|
|
||||||
# Add tenant ID if it exists
|
@router.get("/{tenant_id}/members")
|
||||||
if hasattr(request.state, 'tenant_id') and request.state.tenant_id:
|
async def get_tenant_members(request: Request, tenant_id: str = Path(...)):
|
||||||
headers["X-Tenant-ID"] = str(request.state.tenant_id)
|
"""Get tenant members"""
|
||||||
elif request.state.user.get("tenant_id"):
|
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/members")
|
||||||
headers["X-Tenant-ID"] = str(request.state.user.get("tenant_id"))
|
|
||||||
|
|
||||||
roles = request.state.user.get("roles", [])
|
# ================================================================
|
||||||
if roles:
|
# TENANT-SCOPED DATA SERVICE ENDPOINTS
|
||||||
headers["X-User-Roles"] = ",".join(roles)
|
# ================================================================
|
||||||
|
|
||||||
permissions = request.state.user.get("permissions", [])
|
@router.api_route("/{tenant_id}/sales/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||||
if permissions:
|
async def proxy_tenant_sales(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||||
headers["X-User-Permissions"] = ",".join(permissions)
|
"""Proxy tenant sales requests to data service"""
|
||||||
|
target_path = f"/api/v1/tenants/{tenant_id}/sales/{path}".rstrip("/")
|
||||||
|
return await _proxy_to_data_service(request, target_path)
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
@router.api_route("/{tenant_id}/weather/{path:path}", methods=["GET", "POST", "OPTIONS"])
|
||||||
response = await client.post(
|
async def proxy_tenant_weather(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||||
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/register",
|
"""Proxy tenant weather requests to data service"""
|
||||||
content=body,
|
target_path = f"/api/v1/tenants/{tenant_id}/weather/{path}".rstrip("/")
|
||||||
headers=headers
|
return await _proxy_to_data_service(request, target_path)
|
||||||
)
|
|
||||||
|
|
||||||
return JSONResponse(
|
@router.api_route("/{tenant_id}/analytics/{path:path}", methods=["GET", "POST", "OPTIONS"])
|
||||||
status_code=response.status_code,
|
async def proxy_tenant_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||||
content=response.json()
|
"""Proxy tenant analytics requests to data service"""
|
||||||
)
|
target_path = f"/api/v1/tenants/{tenant_id}/analytics/{path}".rstrip("/")
|
||||||
|
return await _proxy_to_data_service(request, target_path)
|
||||||
|
|
||||||
except httpx.RequestError as e:
|
# ================================================================
|
||||||
logger.error(f"Tenant service unavailable: {e}")
|
# TENANT-SCOPED TRAINING SERVICE ENDPOINTS
|
||||||
raise HTTPException(
|
# ================================================================
|
||||||
status_code=503,
|
|
||||||
detail="Tenant service unavailable"
|
@router.api_route("/{tenant_id}/training/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||||
|
async def proxy_tenant_training(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||||
|
"""Proxy tenant training requests to training service"""
|
||||||
|
target_path = f"/api/v1/tenants/{tenant_id}/training/{path}".rstrip("/")
|
||||||
|
return await _proxy_to_training_service(request, target_path)
|
||||||
|
|
||||||
|
@router.api_route("/{tenant_id}/models/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||||
|
async def proxy_tenant_models(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||||
|
"""Proxy tenant model requests to training service"""
|
||||||
|
target_path = f"/api/v1/tenants/{tenant_id}/models/{path}".rstrip("/")
|
||||||
|
return await _proxy_to_training_service(request, target_path)
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# TENANT-SCOPED FORECASTING SERVICE ENDPOINTS
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
@router.api_route("/{tenant_id}/forecasts/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||||
|
async def proxy_tenant_forecasts(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||||
|
"""Proxy tenant forecast requests to forecasting service"""
|
||||||
|
target_path = f"/api/v1/tenants/{tenant_id}/forecasts/{path}".rstrip("/")
|
||||||
|
return await _proxy_to_forecasting_service(request, target_path)
|
||||||
|
|
||||||
|
@router.api_route("/{tenant_id}/predictions/{path:path}", methods=["GET", "POST", "OPTIONS"])
|
||||||
|
async def proxy_tenant_predictions(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||||
|
"""Proxy tenant prediction requests to forecasting service"""
|
||||||
|
target_path = f"/api/v1/tenants/{tenant_id}/predictions/{path}".rstrip("/")
|
||||||
|
return await _proxy_to_forecasting_service(request, target_path)
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# TENANT-SCOPED NOTIFICATION SERVICE ENDPOINTS
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
@router.api_route("/{tenant_id}/notifications/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||||
|
async def proxy_tenant_notifications(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||||
|
"""Proxy tenant notification requests to notification service"""
|
||||||
|
target_path = f"/api/v1/tenants/{tenant_id}/notifications/{path}".rstrip("/")
|
||||||
|
return await _proxy_to_notification_service(request, target_path)
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# PROXY HELPER FUNCTIONS
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
async def _proxy_to_tenant_service(request: Request, target_path: str):
|
||||||
|
"""Proxy request to tenant service"""
|
||||||
|
return await _proxy_request(request, target_path, settings.TENANT_SERVICE_URL)
|
||||||
|
|
||||||
|
async def _proxy_to_data_service(request: Request, target_path: str):
|
||||||
|
"""Proxy request to data service"""
|
||||||
|
return await _proxy_request(request, target_path, settings.DATA_SERVICE_URL)
|
||||||
|
|
||||||
|
async def _proxy_to_training_service(request: Request, target_path: str):
|
||||||
|
"""Proxy request to training service"""
|
||||||
|
return await _proxy_request(request, target_path, settings.TRAINING_SERVICE_URL)
|
||||||
|
|
||||||
|
async def _proxy_to_forecasting_service(request: Request, target_path: str):
|
||||||
|
"""Proxy request to forecasting service"""
|
||||||
|
return await _proxy_request(request, target_path, settings.FORECASTING_SERVICE_URL)
|
||||||
|
|
||||||
|
async def _proxy_to_notification_service(request: Request, target_path: str):
|
||||||
|
"""Proxy request to notification service"""
|
||||||
|
return await _proxy_request(request, target_path, settings.NOTIFICATION_SERVICE_URL)
|
||||||
|
|
||||||
|
async def _proxy_request(request: Request, target_path: str, service_url: str):
|
||||||
|
"""Generic proxy function with enhanced error handling"""
|
||||||
|
|
||||||
|
# Handle OPTIONS requests directly for CORS
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return Response(
|
||||||
|
status_code=200,
|
||||||
|
headers={
|
||||||
|
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
|
||||||
|
"Access-Control-Allow-Credentials": "true",
|
||||||
|
"Access-Control-Max-Age": "86400"
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/")
|
|
||||||
async def get_tenants(request: Request):
|
|
||||||
"""Get tenants"""
|
|
||||||
try:
|
try:
|
||||||
# ✅ FIX: Same pattern for GET requests
|
url = f"{service_url}{target_path}"
|
||||||
|
|
||||||
|
# Forward headers and add user/tenant context
|
||||||
headers = dict(request.headers)
|
headers = dict(request.headers)
|
||||||
headers.pop("host", None)
|
headers.pop("host", None)
|
||||||
|
|
||||||
# Add user context from gateway auth
|
|
||||||
if hasattr(request.state, 'user'):
|
|
||||||
headers["X-User-ID"] = str(request.state.user.get("user_id"))
|
|
||||||
headers["X-User-Email"] = request.state.user.get("email", "")
|
|
||||||
headers["X-User-Role"] = request.state.user.get("role", "user")
|
|
||||||
|
|
||||||
if hasattr(request.state, 'tenant_id') and request.state.tenant_id:
|
|
||||||
headers["X-Tenant-ID"] = str(request.state.tenant_id)
|
|
||||||
elif request.state.user.get("tenant_id"):
|
|
||||||
headers["X-Tenant-ID"] = str(request.state.user.get("tenant_id"))
|
|
||||||
|
|
||||||
roles = request.state.user.get("roles", [])
|
|
||||||
if roles:
|
|
||||||
headers["X-User-Roles"] = ",".join(roles)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.get(
|
|
||||||
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants",
|
|
||||||
headers=headers
|
|
||||||
)
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=response.status_code,
|
|
||||||
content=response.json()
|
|
||||||
)
|
|
||||||
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
logger.error(f"Tenant service unavailable: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Tenant service unavailable"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ✅ ADD: Generic proxy function like the data service has
|
|
||||||
async def _proxy_tenant_request(request: Request, target_path: str, method: str = None):
|
|
||||||
"""Proxy request to tenant service with user context"""
|
|
||||||
try:
|
|
||||||
url = f"{settings.TENANT_SERVICE_URL}{target_path}"
|
|
||||||
|
|
||||||
# Forward headers with user context
|
|
||||||
headers = dict(request.headers)
|
|
||||||
headers.pop("host", None)
|
|
||||||
|
|
||||||
# Add user context from gateway authentication
|
|
||||||
if hasattr(request.state, 'user'):
|
|
||||||
headers["X-User-ID"] = str(request.state.user.get("user_id"))
|
|
||||||
headers["X-User-Email"] = request.state.user.get("email", "")
|
|
||||||
headers["X-User-Role"] = request.state.user.get("role", "user")
|
|
||||||
|
|
||||||
if hasattr(request.state, 'tenant_id') and request.state.tenant_id:
|
|
||||||
headers["X-Tenant-ID"] = str(request.state.tenant_id)
|
|
||||||
elif request.state.user.get("tenant_id"):
|
|
||||||
headers["X-Tenant-ID"] = str(request.state.user.get("tenant_id"))
|
|
||||||
|
|
||||||
roles = request.state.user.get("roles", [])
|
|
||||||
if roles:
|
|
||||||
headers["X-User-Roles"] = ",".join(roles)
|
|
||||||
|
|
||||||
# Get request body if present
|
# Get request body if present
|
||||||
body = None
|
body = None
|
||||||
request_method = method or request.method
|
if request.method in ["POST", "PUT", "PATCH"]:
|
||||||
if request_method in ["POST", "PUT", "PATCH"]:
|
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
|
|
||||||
|
# Add query parameters
|
||||||
|
params = dict(request.query_params)
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
response = await client.request(
|
response = await client.request(
|
||||||
method=request_method,
|
method=request.method,
|
||||||
url=url,
|
url=url,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
content=body,
|
content=body,
|
||||||
params=dict(request.query_params)
|
params=params
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Handle different response types
|
||||||
|
if response.headers.get("content-type", "").startswith("application/json"):
|
||||||
|
try:
|
||||||
|
content = response.json()
|
||||||
|
except:
|
||||||
|
content = {"message": "Invalid JSON response from service"}
|
||||||
|
else:
|
||||||
|
content = response.text
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=response.status_code,
|
status_code=response.status_code,
|
||||||
content=response.json()
|
content=content
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except httpx.TimeoutError:
|
||||||
|
logger.error(f"Timeout calling {service_url}{target_path}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=504,
|
||||||
|
detail=f"Service timeout"
|
||||||
|
)
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
logger.error(f"Tenant service unavailable: {e}")
|
logger.error(f"Request error calling {service_url}{target_path}: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="Tenant service unavailable"
|
detail=f"Service unavailable"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error proxying to {service_url}{target_path}: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Internal gateway error"
|
||||||
)
|
)
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
"""
|
|
||||||
Training routes for gateway - FIXED VERSION
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, HTTPException, Query, Response
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
import httpx
|
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
async def _proxy_training_request(request: Request, target_path: str, method: str = None):
|
|
||||||
"""Proxy request to training service with user context"""
|
|
||||||
|
|
||||||
# Handle OPTIONS requests directly for CORS
|
|
||||||
if request.method == "OPTIONS":
|
|
||||||
return Response(
|
|
||||||
status_code=200,
|
|
||||||
headers={
|
|
||||||
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
|
|
||||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
||||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
|
|
||||||
"Access-Control-Allow-Credentials": "true",
|
|
||||||
"Access-Control-Max-Age": "86400" # Cache preflight for 24 hours
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
url = f"{settings.TRAINING_SERVICE_URL}{target_path}"
|
|
||||||
|
|
||||||
# Forward headers AND add user context from gateway auth
|
|
||||||
headers = dict(request.headers)
|
|
||||||
headers.pop("host", None) # Remove host header
|
|
||||||
|
|
||||||
# ✅ ADD USER CONTEXT FROM GATEWAY AUTHENTICATION
|
|
||||||
# Gateway middleware already verified the token and added user to request.state
|
|
||||||
if hasattr(request.state, 'user'):
|
|
||||||
headers["X-User-ID"] = str(request.state.user.get("user_id"))
|
|
||||||
headers["X-User-Email"] = request.state.user.get("email", "")
|
|
||||||
headers["X-Tenant-ID"] = str(request.state.user.get("tenant_id"))
|
|
||||||
headers["X-User-Roles"] = ",".join(request.state.user.get("roles", []))
|
|
||||||
headers["X-User-Permissions"] = ",".join(request.state.user.get("permissions", []))
|
|
||||||
|
|
||||||
# Get request body if present
|
|
||||||
body = None
|
|
||||||
request_method = method or request.method
|
|
||||||
if request_method in ["POST", "PUT", "PATCH"]:
|
|
||||||
body = await request.body()
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
||||||
response = await client.request(
|
|
||||||
method=request_method,
|
|
||||||
url=url,
|
|
||||||
headers=headers,
|
|
||||||
content=body,
|
|
||||||
params=dict(request.query_params)
|
|
||||||
)
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=response.status_code,
|
|
||||||
content=response.json()
|
|
||||||
)
|
|
||||||
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
logger.error(f"Training service unavailable: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Training service unavailable"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Training service error: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
@router.get("/status/{training_job_id}")
|
|
||||||
async def get_training_status(training_job_id: str, request: Request):
|
|
||||||
"""Get training job status"""
|
|
||||||
return await _proxy_training_request(request, f"/training/status/{training_job_id}", "GET")
|
|
||||||
|
|
||||||
@router.get("/models")
|
|
||||||
async def get_trained_models(request: Request):
|
|
||||||
"""Get trained models"""
|
|
||||||
return await _proxy_training_request(request, "/training/models", "GET")
|
|
||||||
|
|
||||||
@router.get("/jobs")
|
|
||||||
async def get_training_jobs(
|
|
||||||
request: Request,
|
|
||||||
limit: Optional[int] = Query(10, ge=1, le=100),
|
|
||||||
offset: Optional[int] = Query(0, ge=0)
|
|
||||||
):
|
|
||||||
"""Get training jobs"""
|
|
||||||
return await _proxy_training_request(request, f"/training/jobs?limit={limit}&offset={offset}", "GET")
|
|
||||||
|
|
||||||
@router.post("/jobs")
|
|
||||||
async def start_training_job(request: Request):
|
|
||||||
"""Start a new training job - Proxy to training service"""
|
|
||||||
return await _proxy_training_request(request, "/training/jobs", "POST")
|
|
||||||
@@ -21,7 +21,7 @@ from app.core.security import SecurityManager
|
|||||||
from shared.monitoring.decorators import track_execution_time
|
from shared.monitoring.decorators import track_execution_time
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
router = APIRouter()
|
router = APIRouter(tags=["authentication"])
|
||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
def get_metrics_collector(request: Request):
|
def get_metrics_collector(request: Request):
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from shared.auth.decorators import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
router = APIRouter()
|
router = APIRouter(tags=["users"])
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/me", response_model=UserResponse)
|
||||||
async def get_current_user_info(
|
async def get_current_user_info(
|
||||||
@@ -78,45 +78,3 @@ async def update_current_user(
|
|||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Failed to update user"
|
detail="Failed to update user"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/change-password")
|
|
||||||
async def change_password(
|
|
||||||
password_data: PasswordChange,
|
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Change user password"""
|
|
||||||
try:
|
|
||||||
await UserService.change_password(
|
|
||||||
current_user.id,
|
|
||||||
password_data.current_password,
|
|
||||||
password_data.new_password,
|
|
||||||
db
|
|
||||||
)
|
|
||||||
return {"message": "Password changed successfully"}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Password change error: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Failed to change password"
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.delete("/me")
|
|
||||||
async def delete_current_user(
|
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Delete current user account"""
|
|
||||||
try:
|
|
||||||
await UserService.delete_user(current_user.id, db)
|
|
||||||
return {"message": "User account deleted successfully"}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Delete user error: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Failed to delete user account"
|
|
||||||
)
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
# ================================================================
|
# ================================================================
|
||||||
# services/data/app/api/sales.py - UPDATED WITH UNIFIED AUTH
|
# services/data/app/api/sales.py - FIXED FOR NEW TENANT-SCOPED ARCHITECTURE
|
||||||
# ================================================================
|
# ================================================================
|
||||||
"""Sales data API endpoints with unified authentication"""
|
"""Sales data API endpoints with tenant-scoped URLs"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query, Response
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query, Response, Path
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
import uuid
|
from uuid import UUID
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import base64
|
import base64
|
||||||
import structlog
|
import structlog
|
||||||
@@ -31,22 +31,23 @@ from app.services.messaging import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Import unified authentication from shared library
|
# Import unified authentication from shared library
|
||||||
from shared.auth.decorators import (
|
from shared.auth.decorators import get_current_user_dep
|
||||||
get_current_user_dep,
|
|
||||||
get_current_tenant_id_dep
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(tags=["sales"])
|
router = APIRouter(tags=["sales"])
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
@router.post("/", response_model=SalesDataResponse)
|
# ================================================================
|
||||||
|
# TENANT-SCOPED SALES ENDPOINTS
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
@router.post("/tenants/{tenant_id}/sales", response_model=SalesDataResponse)
|
||||||
async def create_sales_record(
|
async def create_sales_record(
|
||||||
sales_data: SalesDataCreate,
|
sales_data: SalesDataCreate,
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Create a new sales record"""
|
"""Create a new sales record for tenant"""
|
||||||
try:
|
try:
|
||||||
logger.debug("Creating sales record",
|
logger.debug("Creating sales record",
|
||||||
product=sales_data.product_name,
|
product=sales_data.product_name,
|
||||||
@@ -54,7 +55,7 @@ async def create_sales_record(
|
|||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
user_id=current_user["user_id"])
|
user_id=current_user["user_id"])
|
||||||
|
|
||||||
# Override tenant_id from token/header
|
# Override tenant_id from URL path (gateway already verified access)
|
||||||
sales_data.tenant_id = tenant_id
|
sales_data.tenant_id = tenant_id
|
||||||
|
|
||||||
record = await SalesService.create_sales_record(sales_data, db)
|
record = await SalesService.create_sales_record(sales_data, db)
|
||||||
@@ -85,14 +86,14 @@ async def create_sales_record(
|
|||||||
tenant_id=tenant_id)
|
tenant_id=tenant_id)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to create sales record: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to create sales record: {str(e)}")
|
||||||
|
|
||||||
@router.post("/bulk", response_model=List[SalesDataResponse])
|
@router.post("/tenants/{tenant_id}/sales/bulk", response_model=List[SalesDataResponse])
|
||||||
async def create_bulk_sales(
|
async def create_bulk_sales(
|
||||||
sales_data: List[SalesDataCreate],
|
sales_data: List[SalesDataCreate],
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Create multiple sales records"""
|
"""Create multiple sales records for tenant"""
|
||||||
try:
|
try:
|
||||||
logger.debug("Creating bulk sales records",
|
logger.debug("Creating bulk sales records",
|
||||||
count=len(sales_data),
|
count=len(sales_data),
|
||||||
@@ -127,16 +128,16 @@ async def create_bulk_sales(
|
|||||||
tenant_id=tenant_id)
|
tenant_id=tenant_id)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to create bulk sales records: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to create bulk sales records: {str(e)}")
|
||||||
|
|
||||||
@router.get("/", response_model=List[SalesDataResponse])
|
@router.get("/tenants/{tenant_id}/sales", response_model=List[SalesDataResponse])
|
||||||
async def get_sales_data(
|
async def get_sales_data(
|
||||||
start_date: Optional[datetime] = Query(None),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
end_date: Optional[datetime] = Query(None),
|
start_date: Optional[datetime] = Query(None, description="Start date filter"),
|
||||||
product_name: Optional[str] = Query(None),
|
end_date: Optional[datetime] = Query(None, description="End date filter"),
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
product_name: Optional[str] = Query(None, description="Product name filter"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get sales data with filters"""
|
"""Get sales data for tenant with filters"""
|
||||||
try:
|
try:
|
||||||
logger.debug("Querying sales data",
|
logger.debug("Querying sales data",
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -164,15 +165,15 @@ async def get_sales_data(
|
|||||||
tenant_id=tenant_id)
|
tenant_id=tenant_id)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to query sales data: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to query sales data: {str(e)}")
|
||||||
|
|
||||||
@router.post("/import", response_model=SalesImportResult)
|
@router.post("/tenants/{tenant_id}/sales/import", response_model=SalesImportResult)
|
||||||
async def import_sales_data(
|
async def import_sales_data(
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
file_format: str = Form(...),
|
file_format: str = Form(...),
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Import sales data from file"""
|
"""Import sales data from file for tenant"""
|
||||||
try:
|
try:
|
||||||
logger.info("Importing sales data",
|
logger.info("Importing sales data",
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -220,26 +221,27 @@ async def import_sales_data(
|
|||||||
tenant_id=tenant_id)
|
tenant_id=tenant_id)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to import sales data: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to import sales data: {str(e)}")
|
||||||
|
|
||||||
@router.post("/import/validate", response_model=SalesValidationResult)
|
@router.post("/tenants/{tenant_id}/sales/import/validate", response_model=SalesValidationResult)
|
||||||
async def validate_import_data(
|
async def validate_import_data(
|
||||||
import_data: SalesDataImport,
|
import_data: SalesDataImport,
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
||||||
):
|
):
|
||||||
"""Validate import data before processing"""
|
"""Validate import data - Gateway already verified tenant access"""
|
||||||
try:
|
try:
|
||||||
logger.debug("Validating import data", tenant_id=tenant_id)
|
logger.debug("Validating import data",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=current_user["user_id"])
|
||||||
|
|
||||||
# Override tenant_id
|
# Set tenant context from URL path
|
||||||
import_data.tenant_id = tenant_id
|
import_data.tenant_id = tenant_id
|
||||||
|
|
||||||
validation = await DataImportService.validate_import_data(
|
validation = await DataImportService.validate_import_data(import_data.model_dump())
|
||||||
import_data.model_dump()
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug("Validation completed",
|
logger.debug("Validation completed",
|
||||||
is_valid=validation.get("is_valid", False),
|
is_valid=validation.get("is_valid", False),
|
||||||
tenant_id=tenant_id)
|
tenant_id=tenant_id)
|
||||||
|
|
||||||
return validation
|
return validation
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -248,15 +250,17 @@ async def validate_import_data(
|
|||||||
tenant_id=tenant_id)
|
tenant_id=tenant_id)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to validate import data: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to validate import data: {str(e)}")
|
||||||
|
|
||||||
@router.get("/import/template/{format_type}")
|
@router.get("/tenants/{tenant_id}/sales/import/template/{format_type}")
|
||||||
async def get_import_template(
|
async def get_import_template(
|
||||||
format_type: str,
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
format_type: str = Path(..., description="Template format: csv, json, excel"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
||||||
):
|
):
|
||||||
"""Get import template for specified format"""
|
"""Get import template for specified format"""
|
||||||
try:
|
try:
|
||||||
logger.debug("Getting import template",
|
logger.debug("Getting import template",
|
||||||
format=format_type,
|
format=format_type,
|
||||||
|
tenant_id=tenant_id,
|
||||||
user_id=current_user["user_id"])
|
user_id=current_user["user_id"])
|
||||||
|
|
||||||
template = await DataImportService.get_import_template(format_type)
|
template = await DataImportService.get_import_template(format_type)
|
||||||
@@ -265,7 +269,9 @@ async def get_import_template(
|
|||||||
logger.warning("Template generation error", error=template["error"])
|
logger.warning("Template generation error", error=template["error"])
|
||||||
raise HTTPException(status_code=400, detail=template["error"])
|
raise HTTPException(status_code=400, detail=template["error"])
|
||||||
|
|
||||||
logger.debug("Template generated successfully", format=format_type)
|
logger.debug("Template generated successfully",
|
||||||
|
format=format_type,
|
||||||
|
tenant_id=tenant_id)
|
||||||
|
|
||||||
if format_type.lower() == "csv":
|
if format_type.lower() == "csv":
|
||||||
return Response(
|
return Response(
|
||||||
@@ -291,14 +297,16 @@ async def get_import_template(
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to generate import template", error=str(e))
|
logger.error("Failed to generate import template",
|
||||||
|
error=str(e),
|
||||||
|
tenant_id=tenant_id)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to generate template: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to generate template: {str(e)}")
|
||||||
|
|
||||||
@router.get("/analytics")
|
@router.get("/tenants/{tenant_id}/sales/analytics")
|
||||||
async def get_sales_analytics(
|
async def get_sales_analytics(
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
start_date: Optional[datetime] = Query(None, description="Start date"),
|
start_date: Optional[datetime] = Query(None, description="Start date"),
|
||||||
end_date: Optional[datetime] = Query(None, description="End date"),
|
end_date: Optional[datetime] = Query(None, description="End date"),
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
@@ -322,17 +330,17 @@ async def get_sales_analytics(
|
|||||||
tenant_id=tenant_id)
|
tenant_id=tenant_id)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to generate analytics: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to generate analytics: {str(e)}")
|
||||||
|
|
||||||
@router.post("/export")
|
@router.post("/tenants/{tenant_id}/sales/export")
|
||||||
async def export_sales_data(
|
async def export_sales_data(
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
export_format: str = Query("csv", description="Export format: csv, excel, json"),
|
export_format: str = Query("csv", description="Export format: csv, excel, json"),
|
||||||
start_date: Optional[datetime] = Query(None, description="Start date"),
|
start_date: Optional[datetime] = Query(None, description="Start date"),
|
||||||
end_date: Optional[datetime] = Query(None, description="End date"),
|
end_date: Optional[datetime] = Query(None, description="End date"),
|
||||||
products: Optional[List[str]] = Query(None, description="Filter by products"),
|
products: Optional[List[str]] = Query(None, description="Filter by products"),
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Export sales data in specified format"""
|
"""Export sales data in specified format for tenant"""
|
||||||
try:
|
try:
|
||||||
logger.info("Exporting sales data",
|
logger.info("Exporting sales data",
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -376,14 +384,14 @@ async def export_sales_data(
|
|||||||
tenant_id=tenant_id)
|
tenant_id=tenant_id)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to export sales data: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to export sales data: {str(e)}")
|
||||||
|
|
||||||
@router.delete("/{record_id}")
|
@router.delete("/tenants/{tenant_id}/sales/{record_id}")
|
||||||
async def delete_sales_record(
|
async def delete_sales_record(
|
||||||
record_id: str,
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
record_id: str = Path(..., description="Sales record ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Delete a sales record"""
|
"""Delete a sales record for tenant"""
|
||||||
try:
|
try:
|
||||||
logger.info("Deleting sales record",
|
logger.info("Deleting sales record",
|
||||||
record_id=record_id,
|
record_id=record_id,
|
||||||
@@ -413,14 +421,14 @@ async def delete_sales_record(
|
|||||||
tenant_id=tenant_id)
|
tenant_id=tenant_id)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to delete sales record: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to delete sales record: {str(e)}")
|
||||||
|
|
||||||
@router.get("/summary")
|
@router.get("/tenants/{tenant_id}/sales/summary")
|
||||||
async def get_sales_summary(
|
async def get_sales_summary(
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
period: str = Query("daily", description="Summary period: daily, weekly, monthly"),
|
period: str = Query("daily", description="Summary period: daily, weekly, monthly"),
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get sales summary for specified period"""
|
"""Get sales summary for specified period for tenant"""
|
||||||
try:
|
try:
|
||||||
logger.debug("Getting sales summary",
|
logger.debug("Getting sales summary",
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -437,13 +445,13 @@ async def get_sales_summary(
|
|||||||
tenant_id=tenant_id)
|
tenant_id=tenant_id)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to generate summary: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to generate summary: {str(e)}")
|
||||||
|
|
||||||
@router.get("/products")
|
@router.get("/tenants/{tenant_id}/sales/products")
|
||||||
async def get_products_list(
|
async def get_products_list(
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get list of all products with sales data"""
|
"""Get list of all products with sales data for tenant"""
|
||||||
try:
|
try:
|
||||||
logger.debug("Getting products list", tenant_id=tenant_id)
|
logger.debug("Getting products list", tenant_id=tenant_id)
|
||||||
|
|
||||||
@@ -459,3 +467,77 @@ async def get_products_list(
|
|||||||
error=str(e),
|
error=str(e),
|
||||||
tenant_id=tenant_id)
|
tenant_id=tenant_id)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to get products list: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to get products list: {str(e)}")
|
||||||
|
|
||||||
|
@router.get("/tenants/{tenant_id}/sales/{record_id}", response_model=SalesDataResponse)
|
||||||
|
async def get_sales_record(
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
record_id: str = Path(..., description="Sales record ID"),
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get a specific sales record for tenant"""
|
||||||
|
try:
|
||||||
|
logger.debug("Getting sales record",
|
||||||
|
record_id=record_id,
|
||||||
|
tenant_id=tenant_id)
|
||||||
|
|
||||||
|
record = await SalesService.get_sales_record(record_id, db)
|
||||||
|
|
||||||
|
if not record or record.tenant_id != tenant_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Sales record not found")
|
||||||
|
|
||||||
|
logger.debug("Sales record retrieved",
|
||||||
|
record_id=record_id,
|
||||||
|
tenant_id=tenant_id)
|
||||||
|
return record
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get sales record",
|
||||||
|
error=str(e),
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
record_id=record_id)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to get sales record: {str(e)}")
|
||||||
|
|
||||||
|
@router.put("/tenants/{tenant_id}/sales/{record_id}", response_model=SalesDataResponse)
|
||||||
|
async def update_sales_record(
|
||||||
|
sales_data: SalesDataCreate,
|
||||||
|
record_id: str = Path(..., description="Sales record ID"),
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Update a sales record for tenant"""
|
||||||
|
try:
|
||||||
|
logger.info("Updating sales record",
|
||||||
|
record_id=record_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=current_user["user_id"])
|
||||||
|
|
||||||
|
# Verify record exists and belongs to tenant
|
||||||
|
existing_record = await SalesService.get_sales_record(record_id, db)
|
||||||
|
if not existing_record or existing_record.tenant_id != tenant_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Sales record not found")
|
||||||
|
|
||||||
|
# Override tenant_id from URL path
|
||||||
|
sales_data.tenant_id = tenant_id
|
||||||
|
|
||||||
|
updated_record = await SalesService.update_sales_record(record_id, sales_data, db)
|
||||||
|
|
||||||
|
if not updated_record:
|
||||||
|
raise HTTPException(status_code=404, detail="Sales record not found")
|
||||||
|
|
||||||
|
logger.info("Sales record updated successfully",
|
||||||
|
record_id=record_id,
|
||||||
|
tenant_id=tenant_id)
|
||||||
|
return updated_record
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to update sales record",
|
||||||
|
error=str(e),
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
record_id=record_id)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to update sales record: {str(e)}")
|
||||||
@@ -3,11 +3,12 @@
|
|||||||
# ================================================================
|
# ================================================================
|
||||||
"""Traffic data API endpoints with improved error handling"""
|
"""Traffic data API endpoints with improved error handling"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import structlog
|
import structlog
|
||||||
|
from uuid import UUID
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.services.traffic_service import TrafficService
|
from app.services.traffic_service import TrafficService
|
||||||
@@ -23,14 +24,15 @@ from shared.auth.decorators import (
|
|||||||
get_current_tenant_id_dep
|
get_current_tenant_id_dep
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter(tags=["traffic"])
|
||||||
traffic_service = TrafficService()
|
traffic_service = TrafficService()
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
@router.get("/current", response_model=TrafficDataResponse)
|
@router.get("/tenants/{tenant_id}/current", response_model=TrafficDataResponse)
|
||||||
async def get_current_traffic(
|
async def get_current_traffic(
|
||||||
latitude: float = Query(..., description="Latitude"),
|
latitude: float = Query(..., description="Latitude"),
|
||||||
longitude: float = Query(..., description="Longitude"),
|
longitude: float = Query(..., description="Longitude"),
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
):
|
):
|
||||||
"""Get current traffic data for location"""
|
"""Get current traffic data for location"""
|
||||||
@@ -69,13 +71,14 @@ async def get_current_traffic(
|
|||||||
logger.error("Traffic API traceback", traceback=traceback.format_exc())
|
logger.error("Traffic API traceback", traceback=traceback.format_exc())
|
||||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||||
|
|
||||||
@router.get("/historical", response_model=List[TrafficDataResponse])
|
@router.get("/tenants/{tenant_id}/historical", response_model=List[TrafficDataResponse])
|
||||||
async def get_historical_traffic(
|
async def get_historical_traffic(
|
||||||
latitude: float = Query(..., description="Latitude"),
|
latitude: float = Query(..., description="Latitude"),
|
||||||
longitude: float = Query(..., description="Longitude"),
|
longitude: float = Query(..., description="Longitude"),
|
||||||
start_date: datetime = Query(..., description="Start date"),
|
start_date: datetime = Query(..., description="Start date"),
|
||||||
end_date: datetime = Query(..., description="End date"),
|
end_date: datetime = Query(..., description="End date"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
):
|
):
|
||||||
"""Get historical traffic data"""
|
"""Get historical traffic data"""
|
||||||
@@ -115,11 +118,12 @@ async def get_historical_traffic(
|
|||||||
logger.error("Unexpected error in historical traffic API", error=str(e))
|
logger.error("Unexpected error in historical traffic API", error=str(e))
|
||||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||||
|
|
||||||
@router.post("/store")
|
@router.post("/tenants/{tenant_id}/store")
|
||||||
async def store_traffic_data(
|
async def store_traffic_data(
|
||||||
latitude: float = Query(..., description="Latitude"),
|
latitude: float = Query(..., description="Latitude"),
|
||||||
longitude: float = Query(..., description="Longitude"),
|
longitude: float = Query(..., description="Longitude"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
||||||
):
|
):
|
||||||
"""Store current traffic data to database"""
|
"""Store current traffic data to database"""
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# services/data/app/api/weather.py - UPDATED WITH UNIFIED AUTH
|
# services/data/app/api/weather.py - UPDATED WITH UNIFIED AUTH
|
||||||
"""Weather data API endpoints with unified authentication"""
|
"""Weather data API endpoints with unified authentication"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
|
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks, Path
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
import structlog
|
import structlog
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from app.schemas.external import (
|
from app.schemas.external import (
|
||||||
WeatherDataResponse,
|
WeatherDataResponse,
|
||||||
@@ -19,14 +20,14 @@ from shared.auth.decorators import (
|
|||||||
get_current_tenant_id_dep
|
get_current_tenant_id_dep
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/weather", tags=["weather"])
|
router = APIRouter(tags=["weather"])
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
@router.get("/current", response_model=WeatherDataResponse)
|
@router.get("/tenants/{tenant_id}/current", response_model=WeatherDataResponse)
|
||||||
async def get_current_weather(
|
async def get_current_weather(
|
||||||
latitude: float = Query(..., description="Latitude"),
|
latitude: float = Query(..., description="Latitude"),
|
||||||
longitude: float = Query(..., description="Longitude"),
|
longitude: float = Query(..., description="Longitude"),
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
):
|
):
|
||||||
"""Get current weather data for location"""
|
"""Get current weather data for location"""
|
||||||
@@ -64,12 +65,12 @@ async def get_current_weather(
|
|||||||
logger.error("Failed to get current weather", error=str(e))
|
logger.error("Failed to get current weather", error=str(e))
|
||||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||||
|
|
||||||
@router.get("/forecast", response_model=List[WeatherForecastResponse])
|
@router.get("/tenants/{tenant_id}/forecast", response_model=List[WeatherForecastResponse])
|
||||||
async def get_weather_forecast(
|
async def get_weather_forecast(
|
||||||
latitude: float = Query(..., description="Latitude"),
|
latitude: float = Query(..., description="Latitude"),
|
||||||
longitude: float = Query(..., description="Longitude"),
|
longitude: float = Query(..., description="Longitude"),
|
||||||
days: int = Query(7, description="Number of forecast days", ge=1, le=14),
|
days: int = Query(7, description="Number of forecast days", ge=1, le=14),
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
):
|
):
|
||||||
"""Get weather forecast for location"""
|
"""Get weather forecast for location"""
|
||||||
@@ -108,13 +109,13 @@ async def get_weather_forecast(
|
|||||||
logger.error("Failed to get weather forecast", error=str(e))
|
logger.error("Failed to get weather forecast", error=str(e))
|
||||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||||
|
|
||||||
@router.get("/history", response_model=List[WeatherDataResponse])
|
@router.get("/tenants/{tenant_id}/history", response_model=List[WeatherDataResponse])
|
||||||
async def get_weather_history(
|
async def get_weather_history(
|
||||||
start_date: date = Query(..., description="Start date"),
|
start_date: date = Query(..., description="Start date"),
|
||||||
end_date: date = Query(..., description="End date"),
|
end_date: date = Query(..., description="End date"),
|
||||||
latitude: float = Query(..., description="Latitude"),
|
latitude: float = Query(..., description="Latitude"),
|
||||||
longitude: float = Query(..., description="Longitude"),
|
longitude: float = Query(..., description="Longitude"),
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep)
|
tenant_id: str = Path(..., description="Tenant ID")
|
||||||
):
|
):
|
||||||
"""Get historical weather data"""
|
"""Get historical weather data"""
|
||||||
try:
|
try:
|
||||||
@@ -134,11 +135,11 @@ async def get_weather_history(
|
|||||||
logger.error("Failed to get weather history", error=str(e))
|
logger.error("Failed to get weather history", error=str(e))
|
||||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||||
|
|
||||||
@router.post("/sync")
|
@router.post("/tenants/{tenant_id}/sync")
|
||||||
async def sync_weather_data(
|
async def sync_weather_data(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
force: bool = Query(False, description="Force sync even if recently synced"),
|
force: bool = Query(False, description="Force sync even if recently synced"),
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
):
|
):
|
||||||
"""Manually trigger weather data synchronization"""
|
"""Manually trigger weather data synchronization"""
|
||||||
|
|||||||
@@ -108,9 +108,9 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(sales_router, prefix="/api/v1/sales", tags=["sales"])
|
app.include_router(sales_router, prefix="/api/v1", tags=["sales"])
|
||||||
app.include_router(weather_router, prefix="/api/v1/weather", tags=["weather"])
|
app.include_router(weather_router, prefix="/api/v1", tags=["weather"])
|
||||||
app.include_router(traffic_router, prefix="/api/v1/traffic", tags=["traffic"])
|
app.include_router(traffic_router, prefix="/api/v1", tags=["traffic"])
|
||||||
|
|
||||||
# Health check endpoint
|
# Health check endpoint
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
Tenant API endpoints
|
Tenant API endpoints
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
from fastapi import APIRouter, Depends, HTTPException, status, Path
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
import structlog
|
import structlog
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.schemas.tenants import (
|
from app.schemas.tenants import (
|
||||||
@@ -45,8 +46,8 @@ async def register_bakery(
|
|||||||
|
|
||||||
@router.get("/tenants/{tenant_id}/access/{user_id}", response_model=TenantAccessResponse)
|
@router.get("/tenants/{tenant_id}/access/{user_id}", response_model=TenantAccessResponse)
|
||||||
async def verify_tenant_access(
|
async def verify_tenant_access(
|
||||||
tenant_id: str,
|
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Verify if user has access to tenant - Called by Gateway"""
|
"""Verify if user has access to tenant - Called by Gateway"""
|
||||||
@@ -62,7 +63,7 @@ async def verify_tenant_access(
|
|||||||
detail="Access verification failed"
|
detail="Access verification failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/users/{user_id}/tenants", response_model=List[TenantResponse])
|
@router.get("/tenants/users/{user_id}", response_model=List[TenantResponse])
|
||||||
async def get_user_tenants(
|
async def get_user_tenants(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
@@ -89,7 +90,7 @@ async def get_user_tenants(
|
|||||||
|
|
||||||
@router.get("/tenants/{tenant_id}", response_model=TenantResponse)
|
@router.get("/tenants/{tenant_id}", response_model=TenantResponse)
|
||||||
async def get_tenant(
|
async def get_tenant(
|
||||||
tenant_id: str,
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
@@ -113,8 +114,8 @@ async def get_tenant(
|
|||||||
|
|
||||||
@router.put("/tenants/{tenant_id}", response_model=TenantResponse)
|
@router.put("/tenants/{tenant_id}", response_model=TenantResponse)
|
||||||
async def update_tenant(
|
async def update_tenant(
|
||||||
tenant_id: str,
|
|
||||||
update_data: TenantUpdate,
|
update_data: TenantUpdate,
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
@@ -134,9 +135,9 @@ async def update_tenant(
|
|||||||
|
|
||||||
@router.post("/tenants/{tenant_id}/members", response_model=TenantMemberResponse)
|
@router.post("/tenants/{tenant_id}/members", response_model=TenantMemberResponse)
|
||||||
async def add_team_member(
|
async def add_team_member(
|
||||||
tenant_id: str,
|
|
||||||
user_id: str,
|
user_id: str,
|
||||||
role: str,
|
role: str,
|
||||||
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ async def health_check():
|
|||||||
@app.get("/metrics")
|
@app.get("/metrics")
|
||||||
async def metrics():
|
async def metrics():
|
||||||
"""Prometheus metrics endpoint"""
|
"""Prometheus metrics endpoint"""
|
||||||
return metrics_collector.generate_latest()
|
return metrics_collector.get_metrics()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
# ================================================================
|
# ================================================================
|
||||||
"""Training API endpoints with unified authentication"""
|
"""Training API endpoints with unified authentication"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query, Path
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import structlog
|
import structlog
|
||||||
import uuid
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from app.schemas.training import (
|
from app.schemas.training import (
|
||||||
TrainingJobRequest,
|
TrainingJobRequest,
|
||||||
@@ -49,7 +49,7 @@ def get_training_service() -> TrainingService:
|
|||||||
async def start_training_job(
|
async def start_training_job(
|
||||||
request: TrainingJobRequest,
|
request: TrainingJobRequest,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
training_service: TrainingService = Depends(get_training_service),
|
training_service: TrainingService = Depends(get_training_service),
|
||||||
db: AsyncSession = Depends(get_db_session) # Ensure db is available
|
db: AsyncSession = Depends(get_db_session) # Ensure db is available
|
||||||
@@ -57,11 +57,11 @@ async def start_training_job(
|
|||||||
"""Start a new training job for all products"""
|
"""Start a new training job for all products"""
|
||||||
try:
|
try:
|
||||||
|
|
||||||
new_job_id = str(uuid.uuid4())
|
new_job_id = str(uuid4())
|
||||||
|
|
||||||
logger.info("Starting training job",
|
logger.info("Starting training job",
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
job_id=uuid.uuid4(),
|
job_id=uuid4(),
|
||||||
config=request.dict())
|
config=request.dict())
|
||||||
|
|
||||||
# Create training job
|
# Create training job
|
||||||
@@ -115,7 +115,7 @@ async def get_training_jobs(
|
|||||||
status: Optional[TrainingStatus] = Query(None, description="Filter jobs by status"),
|
status: Optional[TrainingStatus] = Query(None, description="Filter jobs by status"),
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
training_service: TrainingService = Depends(get_training_service)
|
training_service: TrainingService = Depends(get_training_service)
|
||||||
):
|
):
|
||||||
@@ -149,7 +149,7 @@ async def get_training_jobs(
|
|||||||
@router.get("/jobs/{job_id}", response_model=TrainingJobResponse)
|
@router.get("/jobs/{job_id}", response_model=TrainingJobResponse)
|
||||||
async def get_training_job(
|
async def get_training_job(
|
||||||
job_id: str,
|
job_id: str,
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
training_service: TrainingService = Depends(get_training_service)
|
training_service: TrainingService = Depends(get_training_service)
|
||||||
):
|
):
|
||||||
@@ -182,7 +182,7 @@ async def get_training_job(
|
|||||||
@router.get("/jobs/{job_id}/progress", response_model=TrainingJobProgress)
|
@router.get("/jobs/{job_id}/progress", response_model=TrainingJobProgress)
|
||||||
async def get_training_progress(
|
async def get_training_progress(
|
||||||
job_id: str,
|
job_id: str,
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
training_service: TrainingService = Depends(get_training_service)
|
training_service: TrainingService = Depends(get_training_service)
|
||||||
):
|
):
|
||||||
@@ -212,7 +212,7 @@ async def get_training_progress(
|
|||||||
@router.post("/jobs/{job_id}/cancel")
|
@router.post("/jobs/{job_id}/cancel")
|
||||||
async def cancel_training_job(
|
async def cancel_training_job(
|
||||||
job_id: str,
|
job_id: str,
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
training_service: TrainingService = Depends(get_training_service)
|
training_service: TrainingService = Depends(get_training_service)
|
||||||
):
|
):
|
||||||
@@ -259,7 +259,7 @@ async def train_single_product(
|
|||||||
product_name: str,
|
product_name: str,
|
||||||
request: SingleProductTrainingRequest,
|
request: SingleProductTrainingRequest,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
training_service: TrainingService = Depends(get_training_service),
|
training_service: TrainingService = Depends(get_training_service),
|
||||||
db: AsyncSession = Depends(get_db_session)
|
db: AsyncSession = Depends(get_db_session)
|
||||||
@@ -312,7 +312,7 @@ async def train_single_product(
|
|||||||
@router.post("/validate", response_model=DataValidationResponse)
|
@router.post("/validate", response_model=DataValidationResponse)
|
||||||
async def validate_training_data(
|
async def validate_training_data(
|
||||||
request: DataValidationRequest,
|
request: DataValidationRequest,
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
training_service: TrainingService = Depends(get_training_service)
|
training_service: TrainingService = Depends(get_training_service)
|
||||||
):
|
):
|
||||||
@@ -343,7 +343,7 @@ async def validate_training_data(
|
|||||||
@router.get("/models")
|
@router.get("/models")
|
||||||
async def get_trained_models(
|
async def get_trained_models(
|
||||||
product_name: Optional[str] = Query(None),
|
product_name: Optional[str] = Query(None),
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
training_service: TrainingService = Depends(get_training_service)
|
training_service: TrainingService = Depends(get_training_service)
|
||||||
):
|
):
|
||||||
@@ -374,7 +374,7 @@ async def get_trained_models(
|
|||||||
@require_role("admin") # Only admins can delete models
|
@require_role("admin") # Only admins can delete models
|
||||||
async def delete_model(
|
async def delete_model(
|
||||||
model_id: str,
|
model_id: str,
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
training_service: TrainingService = Depends(get_training_service)
|
training_service: TrainingService = Depends(get_training_service)
|
||||||
):
|
):
|
||||||
@@ -411,7 +411,7 @@ async def delete_model(
|
|||||||
async def get_training_stats(
|
async def get_training_stats(
|
||||||
start_date: Optional[datetime] = Query(None),
|
start_date: Optional[datetime] = Query(None),
|
||||||
end_date: Optional[datetime] = Query(None),
|
end_date: Optional[datetime] = Query(None),
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
training_service: TrainingService = Depends(get_training_service)
|
training_service: TrainingService = Depends(get_training_service)
|
||||||
):
|
):
|
||||||
@@ -442,7 +442,7 @@ async def get_training_stats(
|
|||||||
async def retrain_all_products(
|
async def retrain_all_products(
|
||||||
request: TrainingJobRequest,
|
request: TrainingJobRequest,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
training_service: TrainingService = Depends(get_training_service)
|
training_service: TrainingService = Depends(get_training_service)
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -173,18 +173,10 @@ async def global_exception_handler(request: Request, exc: Exception):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Include API routers - NO AUTH DEPENDENCIES HERE
|
# Include API routers
|
||||||
# Authentication is handled by API Gateway
|
app.include_router(training.router, prefix="/api/v1", tags=["training"])
|
||||||
app.include_router(
|
app.include_router(models.router, prefix="/api/v1", tags=["models"])
|
||||||
training.router,
|
|
||||||
tags=["training"]
|
|
||||||
)
|
|
||||||
|
|
||||||
app.include_router(
|
|
||||||
models.router,
|
|
||||||
prefix="/models",
|
|
||||||
tags=["models"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Health check endpoints
|
# Health check endpoints
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -488,7 +488,7 @@ class TrainingService:
|
|||||||
params["end_date"] = request.end_date.isoformat()
|
params["end_date"] = request.end_date.isoformat()
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f"{settings.DATA_SERVICE_URL}/weather/history",
|
f"{settings.DATA_SERVICE_URL}/tenants/{tenant_id}/weather/history",
|
||||||
params=params,
|
params=params,
|
||||||
timeout=30.0
|
timeout=30.0
|
||||||
)
|
)
|
||||||
@@ -516,7 +516,7 @@ class TrainingService:
|
|||||||
params["end_date"] = request.end_date.isoformat()
|
params["end_date"] = request.end_date.isoformat()
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f"{settings.DATA_SERVICE_URL}/traffic/historical",
|
f"{settings.DATA_SERVICE_URL}/tenants/{tenant_id}/traffic/historical",
|
||||||
params=params,
|
params=params,
|
||||||
timeout=30.0
|
timeout=30.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -136,21 +136,21 @@ def get_current_tenant_id(request: Request) -> Optional[str]:
|
|||||||
|
|
||||||
def extract_user_from_headers(request: Request) -> Optional[Dict[str, Any]]:
|
def extract_user_from_headers(request: Request) -> Optional[Dict[str, Any]]:
|
||||||
"""Extract user information from forwarded headers (gateway sets these)"""
|
"""Extract user information from forwarded headers (gateway sets these)"""
|
||||||
user_id = request.headers.get("X-User-ID")
|
user_id = request.headers.get("x-user-id")
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"email": request.headers.get("X-User-Email", ""),
|
"email": request.headers.get("x-user-email", ""),
|
||||||
"role": request.headers.get("X-User-Role", "user"),
|
"role": request.headers.get("x-user-role", "user"),
|
||||||
"tenant_id": request.headers.get("X-Tenant-ID"),
|
"tenant_id": request.headers.get("x-tenant-id"),
|
||||||
"permissions": request.headers.get("X-User-Permissions", "").split(",") if request.headers.get("X-User-Permissions") else []
|
"permissions": request.headers.get("X-User-Permissions", "").split(",") if request.headers.get("X-User-Permissions") else []
|
||||||
}
|
}
|
||||||
|
|
||||||
def extract_tenant_from_headers(request: Request) -> Optional[str]:
|
def extract_tenant_from_headers(request: Request) -> Optional[str]:
|
||||||
"""Extract tenant ID from headers"""
|
"""Extract tenant ID from headers"""
|
||||||
return request.headers.get("X-Tenant-ID")
|
return request.headers.get("x-tenant-id")
|
||||||
|
|
||||||
# FastAPI Dependencies for injection
|
# FastAPI Dependencies for injection
|
||||||
async def get_current_user_dep(request: Request) -> Dict[str, Any]:
|
async def get_current_user_dep(request: Request) -> Dict[str, Any]:
|
||||||
|
|||||||
356
shared/auth/tenant_access.py
Normal file
356
shared/auth/tenant_access.py
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
# ================================================================
|
||||||
|
# shared/auth/tenant_access.py - Complete Implementation
|
||||||
|
# ================================================================
|
||||||
|
"""
|
||||||
|
Tenant access control utilities for microservices
|
||||||
|
Provides both gateway-level and service-level tenant access verification
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
from fastapi import HTTPException, Depends
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# Import FastAPI dependencies
|
||||||
|
from shared.auth.decorators import get_current_user_dep
|
||||||
|
|
||||||
|
# Import settings (adjust import path based on your project structure)
|
||||||
|
try:
|
||||||
|
from app.core.config import settings
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
from core.config import settings
|
||||||
|
except ImportError:
|
||||||
|
# Fallback for different project structures
|
||||||
|
import os
|
||||||
|
class Settings:
|
||||||
|
TENANT_SERVICE_URL = os.getenv("TENANT_SERVICE_URL", "http://tenant-service:8000")
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
class TenantAccessManager:
|
||||||
|
"""
|
||||||
|
Centralized tenant access management for both gateway and service level
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, redis_client=None):
|
||||||
|
"""
|
||||||
|
Initialize tenant access manager
|
||||||
|
|
||||||
|
Args:
|
||||||
|
redis_client: Optional Redis client for caching
|
||||||
|
"""
|
||||||
|
self.redis_client = redis_client
|
||||||
|
|
||||||
|
async def verify_basic_tenant_access(self, user_id: str, tenant_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Gateway-level: Basic tenant access verification with caching
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to verify
|
||||||
|
tenant_id: Tenant ID to check access for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if user has access to tenant
|
||||||
|
"""
|
||||||
|
# Check cache first (5-minute TTL)
|
||||||
|
cache_key = f"tenant_access:{user_id}:{tenant_id}"
|
||||||
|
if self.redis_client:
|
||||||
|
try:
|
||||||
|
cached_result = await self.redis_client.get(cache_key)
|
||||||
|
if cached_result is not None:
|
||||||
|
return cached_result.decode() == "true" if isinstance(cached_result, bytes) else cached_result == "true"
|
||||||
|
except Exception as cache_error:
|
||||||
|
logger.warning(f"Cache lookup failed: {cache_error}")
|
||||||
|
|
||||||
|
# Verify with tenant service
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=2.0) as client: # Short timeout for gateway
|
||||||
|
response = await client.get(
|
||||||
|
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/access/{user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
has_access = response.status_code == 200
|
||||||
|
|
||||||
|
# Cache result (5 minutes)
|
||||||
|
if self.redis_client:
|
||||||
|
try:
|
||||||
|
await self.redis_client.setex(cache_key, 300, "true" if has_access else "false")
|
||||||
|
except Exception as cache_error:
|
||||||
|
logger.warning(f"Cache set failed: {cache_error}")
|
||||||
|
|
||||||
|
logger.debug(f"Tenant access check",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
has_access=has_access)
|
||||||
|
|
||||||
|
return has_access
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(f"Timeout verifying tenant access: user={user_id}, tenant={tenant_id}")
|
||||||
|
# Fail open for availability (let service handle detailed check)
|
||||||
|
return True
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Request error verifying tenant access: {e}")
|
||||||
|
# Fail open for availability
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Gateway tenant access verification failed: {e}")
|
||||||
|
# Fail open for availability (let service handle detailed check)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_user_role_in_tenant(self, user_id: str, tenant_id: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get user's role within a specific tenant
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
tenant_id: Tenant ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[str]: User's role in tenant (owner, admin, manager, user) or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/members/{user_id}"
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
role = data.get("role")
|
||||||
|
logger.debug(f"User role in tenant",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
role=role)
|
||||||
|
return role
|
||||||
|
elif response.status_code == 404:
|
||||||
|
logger.debug(f"User not found in tenant",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id)
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unexpected response getting user role: {response.status_code}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get user role in tenant: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def verify_resource_permission(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
resource: str,
|
||||||
|
action: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Fine-grained resource permission check (used by services)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
tenant_id: Tenant ID
|
||||||
|
resource: Resource type (sales, training, forecasts, etc.)
|
||||||
|
action: Action being performed (read, write, delete, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if user has permission
|
||||||
|
"""
|
||||||
|
user_role = await self.get_user_role_in_tenant(user_id, tenant_id)
|
||||||
|
|
||||||
|
if not user_role:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Role-based permission matrix
|
||||||
|
permissions = {
|
||||||
|
"owner": ["*"], # Owners can do everything
|
||||||
|
"admin": ["read", "write", "delete", "manage"],
|
||||||
|
"manager": ["read", "write"],
|
||||||
|
"user": ["read"]
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed_actions = permissions.get(user_role, [])
|
||||||
|
has_permission = "*" in allowed_actions or action in allowed_actions
|
||||||
|
|
||||||
|
logger.debug(f"Resource permission check",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource=resource,
|
||||||
|
action=action,
|
||||||
|
user_role=user_role,
|
||||||
|
has_permission=has_permission)
|
||||||
|
|
||||||
|
return has_permission
|
||||||
|
|
||||||
|
async def get_user_tenants(self, user_id: str) -> list:
|
||||||
|
"""
|
||||||
|
Get all tenants a user has access to
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of tenant dictionaries
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/users/{user_id}"
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
tenants = response.json()
|
||||||
|
logger.debug(f"Retrieved user tenants",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_count=len(tenants))
|
||||||
|
return tenants
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed to get user tenants: {response.status_code}")
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get user tenants: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Global instance for easy import
|
||||||
|
tenant_access_manager = TenantAccessManager()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# FASTAPI DEPENDENCIES
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
async def verify_tenant_access_dep(
|
||||||
|
tenant_id: str,
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
FastAPI dependency to verify tenant access and return tenant_id
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID from path parameter
|
||||||
|
current_user: Current user from auth dependency
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Validated tenant_id
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If user doesn't have access to tenant
|
||||||
|
"""
|
||||||
|
has_access = await tenant_access_manager.verify_user_tenant_access(current_user["user_id"], tenant_id)
|
||||||
|
if not has_access:
|
||||||
|
logger.warning(f"Access denied to tenant",
|
||||||
|
user_id=current_user["user_id"],
|
||||||
|
tenant_id=tenant_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"User {current_user['user_id']} does not have access to tenant {tenant_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"Tenant access verified",
|
||||||
|
user_id=current_user["user_id"],
|
||||||
|
tenant_id=tenant_id)
|
||||||
|
|
||||||
|
return tenant_id
|
||||||
|
|
||||||
|
async def verify_tenant_permission_dep(
|
||||||
|
tenant_id: str,
|
||||||
|
resource: str,
|
||||||
|
action: str,
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
FastAPI dependency to verify tenant access AND resource permission
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID from path parameter
|
||||||
|
resource: Resource type being accessed
|
||||||
|
action: Action being performed
|
||||||
|
current_user: Current user from auth dependency
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Validated tenant_id
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If user doesn't have access or permission
|
||||||
|
"""
|
||||||
|
# First verify basic tenant access
|
||||||
|
has_access = await tenant_access_manager.verify_user_tenant_access(current_user["user_id"], tenant_id)
|
||||||
|
if not has_access:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"Access denied to tenant {tenant_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Then verify specific resource permission
|
||||||
|
has_permission = await tenant_access_manager.verify_resource_permission(
|
||||||
|
current_user["user_id"], tenant_id, resource, action
|
||||||
|
)
|
||||||
|
if not has_permission:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"Insufficient permissions for {action} on {resource}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"Tenant access and permission verified",
|
||||||
|
user_id=current_user["user_id"],
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource=resource,
|
||||||
|
action=action)
|
||||||
|
|
||||||
|
return tenant_id
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# UTILITY FUNCTIONS
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
def extract_tenant_id_from_path(path: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Extract tenant_id from URL path like /api/v1/tenants/{tenant_id}/...
|
||||||
|
BUT NOT from tenant management endpoints like /api/v1/tenants/register
|
||||||
|
"""
|
||||||
|
path_parts = path.split("/")
|
||||||
|
if "tenants" in path_parts:
|
||||||
|
try:
|
||||||
|
tenant_index = path_parts.index("tenants")
|
||||||
|
if tenant_index + 1 < len(path_parts):
|
||||||
|
potential_tenant_id = path_parts[tenant_index + 1]
|
||||||
|
|
||||||
|
# ✅ EXCLUDE tenant management endpoints
|
||||||
|
if potential_tenant_id in ["register", "list"]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return potential_tenant_id
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_tenant_scoped_path(path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if path is tenant-scoped (contains /tenants/{tenant_id}/)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: URL path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if path is tenant-scoped
|
||||||
|
"""
|
||||||
|
return extract_tenant_id_from_path(path) is not None
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# EXPORTS
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Classes
|
||||||
|
"TenantAccessManager",
|
||||||
|
"tenant_access_manager",
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
"verify_tenant_access_dep",
|
||||||
|
"verify_tenant_permission_dep",
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
"extract_tenant_id_from_path",
|
||||||
|
"is_tenant_scoped_path"
|
||||||
|
]
|
||||||
117
test_new.sh
Executable file
117
test_new.sh
Executable file
@@ -0,0 +1,117 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
API_BASE="http://localhost:8000"
|
||||||
|
EMAIL="test@bakery.com"
|
||||||
|
PASSWORD="TestPassword123!"
|
||||||
|
|
||||||
|
echo "🧪 Testing New Tenant-Scoped API Architecture"
|
||||||
|
echo "=============================================="
|
||||||
|
|
||||||
|
# Step 1: Health Check
|
||||||
|
echo "1. Testing Gateway Health..."
|
||||||
|
curl -s -X GET "$API_BASE/health" | echo
|
||||||
|
|
||||||
|
# Step 2: Register User
|
||||||
|
echo -e "\n2. Registering User..."
|
||||||
|
REGISTER_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/auth/register" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"email\": \"$EMAIL\",
|
||||||
|
\"password\": \"$PASSWORD\",
|
||||||
|
\"full_name\": \"Test User\"
|
||||||
|
}")
|
||||||
|
|
||||||
|
echo "Registration Response: $REGISTER_RESPONSE"
|
||||||
|
|
||||||
|
# Step 3: Login
|
||||||
|
echo -e "\n3. Logging in..."
|
||||||
|
LOGIN_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"email\": \"$EMAIL\",
|
||||||
|
\"password\": \"$PASSWORD\"
|
||||||
|
}")
|
||||||
|
|
||||||
|
# Extract token
|
||||||
|
ACCESS_TOKEN=$(echo "$LOGIN_RESPONSE" | grep -o '"access_token":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
echo "Login Response: $LOGIN_RESPONSE"
|
||||||
|
echo "Access Token: ${ACCESS_TOKEN:0:50}..."
|
||||||
|
|
||||||
|
# ✅ NEW: Step 3.5 - Verify Token Works
|
||||||
|
echo -e "\n3.5. Verifying Access Token..."
|
||||||
|
TOKEN_TEST_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/auth/verify" \
|
||||||
|
-H "Authorization: Bearer $ACCESS_TOKEN")
|
||||||
|
|
||||||
|
echo "Token Verification Response: $TOKEN_TEST_RESPONSE"
|
||||||
|
|
||||||
|
# Check if token verification was successful
|
||||||
|
if echo "$TOKEN_TEST_RESPONSE" | grep -q '"user_id"'; then
|
||||||
|
echo "✅ Token verification PASSED"
|
||||||
|
else
|
||||||
|
echo "❌ Token verification FAILED"
|
||||||
|
echo "Stopping test - token is not working"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ✅ NEW: Step 3.6 - Test a Protected Endpoint
|
||||||
|
echo -e "\n3.6. Testing Protected Endpoint (User Profile)..."
|
||||||
|
USER_PROFILE_RESPONSE=$(curl -s -X GET "$API_BASE/api/v1/users/me" \
|
||||||
|
-H "Authorization: Bearer $ACCESS_TOKEN")
|
||||||
|
|
||||||
|
echo "User Profile Response: $USER_PROFILE_RESPONSE"
|
||||||
|
|
||||||
|
# Check if protected endpoint works
|
||||||
|
if echo "$USER_PROFILE_RESPONSE" | grep -q '"email"'; then
|
||||||
|
echo "✅ Protected endpoint access PASSED"
|
||||||
|
else
|
||||||
|
echo "❌ Protected endpoint access FAILED"
|
||||||
|
echo "Response was: $USER_PROFILE_RESPONSE"
|
||||||
|
echo "Continuing with bakery registration anyway..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 4: Register Bakery
|
||||||
|
echo -e "\n4. Registering Bakery..."
|
||||||
|
echo "Using Token: ${ACCESS_TOKEN:0:50}..."
|
||||||
|
echo "Making request to: $API_BASE/api/v1/tenants/register"
|
||||||
|
|
||||||
|
BAKERY_RESPONSE=$(curl -s -v -X POST "$API_BASE/api/v1/tenants/register" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"name": "Test Bakery API",
|
||||||
|
"business_type": "bakery",
|
||||||
|
"address": "Calle Test 123",
|
||||||
|
"city": "Madrid",
|
||||||
|
"postal_code": "28001",
|
||||||
|
"phone": "+34600123456"
|
||||||
|
}' 2>&1)
|
||||||
|
|
||||||
|
echo "Full Response (including headers): $BAKERY_RESPONSE"
|
||||||
|
|
||||||
|
# Extract tenant ID
|
||||||
|
TENANT_ID=$(echo "$BAKERY_RESPONSE" | grep -o '"id":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
echo "Bakery Response: $BAKERY_RESPONSE"
|
||||||
|
echo "Tenant ID: $TENANT_ID"
|
||||||
|
|
||||||
|
# Step 5: Test Tenant-Scoped Endpoint
|
||||||
|
echo -e "\n5. Testing Tenant Sales Endpoint..."
|
||||||
|
SALES_RESPONSE=$(curl -s -X GET "$API_BASE/api/v1/tenants/$TENANT_ID/sales" \
|
||||||
|
-H "Authorization: Bearer $ACCESS_TOKEN")
|
||||||
|
|
||||||
|
echo "Sales Response: $SALES_RESPONSE"
|
||||||
|
|
||||||
|
# Step 6: Test Import Validation
|
||||||
|
echo -e "\n6. Testing Import Validation..."
|
||||||
|
VALIDATION_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/tenants/$TENANT_ID/sales/import/validate" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"data": "date,product,quantity,revenue\n2024-01-01,bread,10,25.50",
|
||||||
|
"data_format": "csv"
|
||||||
|
}')
|
||||||
|
|
||||||
|
echo "Validation Response: $VALIDATION_RESPONSE"
|
||||||
|
|
||||||
|
echo -e "\n✅ API Test Complete!"
|
||||||
|
echo "If you see responses for each step, the new architecture is working!"
|
||||||
Reference in New Issue
Block a user