Compare commits
40 Commits
1599eded86
...
90e6af08d8
| Author | SHA1 | Date | |
|---|---|---|---|
| 90e6af08d8 | |||
| be037e7fa6 | |||
| f41aaf14f7 | |||
| a098ecef5b | |||
| 351d23a6d8 | |||
| a430a7ffd9 | |||
| f79a45b470 | |||
| 12e5f291c6 | |||
| cafb12818a | |||
| 75808b633b | |||
| 5a6bd16d45 | |||
| 5c22021f18 | |||
| 1d16c54811 | |||
| fb3fb4f3d6 | |||
| 591bdf9234 | |||
|
|
c206a018f7 | ||
| 0e1ac45574 | |||
| 32cae30158 | |||
| a5647ecd85 | |||
| 1ca769d451 | |||
| 9cb98f7077 | |||
| 5060f7dda3 | |||
| 1d95da2b4b | |||
| 4baf6b7ac9 | |||
| bc1b333e3e | |||
| 8db2c44f64 | |||
| 4d4a0b8077 | |||
| 072ddbba0c | |||
| f33875e224 | |||
| 172f830645 | |||
| 7b290e84ba | |||
| 5b4d6bf2b2 | |||
| b8079ebf97 | |||
|
|
638d3854af | ||
|
|
56cfbad80e | ||
| fb0d7f0f20 | |||
| 18c8c09d6c | |||
| 6a14d4c0c6 | |||
| 21df3c3558 | |||
| 73338c303f |
1
.gitignore
vendored
1
.gitignore
vendored
@ -14,6 +14,7 @@ media
|
||||
car*.json
|
||||
car_inventory/settings.py
|
||||
car_inventory/__pycache__
|
||||
haikalbot/temp_files_not_included
|
||||
scripts/dsrpipe.py
|
||||
def_venv
|
||||
# Backup files #
|
||||
|
||||
2
.idea/car_inventory.iml
generated
2
.idea/car_inventory.iml
generated
@ -16,7 +16,7 @@
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.11 (car_inventory)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
<orderEntry type="library" name="jquery-3.5.1" level="application" />
|
||||
<orderEntry type="library" name="sweetalert2" level="application" />
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
#!/bin/sh
|
||||
echo "Delete Old Migrations"
|
||||
find ./inventory -type f -iname "00*.py" -delete
|
||||
find ./haikalbot -type f -iname "00*.py" -delete
|
||||
|
||||
echo "Delete Old Cache"
|
||||
find ./car_inventory -type d -iname "__pycache__"|xargs rm -rf
|
||||
find ./inventory -type d -iname "__pycache__"|xargs rm -rf
|
||||
find ./haikalbot -type d -iname "__pycache__"|xargs rm -rf
|
||||
|
||||
|
||||
echo "Apply Base Migrate"
|
||||
|
||||
BIN
haikalbot/.DS_Store
vendored
Normal file
BIN
haikalbot/.DS_Store
vendored
Normal file
Binary file not shown.
@ -1,204 +0,0 @@
|
||||
# Optimizing Qwen3-8B for Arabic Language Support in Django AI Analyst
|
||||
|
||||
This guide provides specific recommendations for using Qwen3-8B with your Django AI Analyst application for Arabic language support.
|
||||
|
||||
## Qwen3-8B Overview
|
||||
|
||||
Qwen3-8B is a powerful multilingual large language model developed by Alibaba Cloud. It offers several advantages for Arabic language processing:
|
||||
|
||||
- **Strong multilingual capabilities**: Trained on diverse multilingual data including Arabic
|
||||
- **Efficient performance**: 8B parameter size balances capability and resource requirements
|
||||
- **Instruction following**: Excellent at following structured instructions in multiple languages
|
||||
- **Context understanding**: Good comprehension of Arabic context and nuances
|
||||
- **JSON formatting**: Reliable at generating structured JSON outputs
|
||||
|
||||
## Configuration Settings for Qwen3-8B
|
||||
|
||||
Update your Django settings to use Qwen3-8B:
|
||||
|
||||
```python
|
||||
# In settings.py
|
||||
OLLAMA_BASE_URL = "http://10.10.1.132:11434"
|
||||
OLLAMA_MODEL = "qwen3:8b"
|
||||
OLLAMA_TIMEOUT = 120 # Seconds
|
||||
```
|
||||
|
||||
## Optimized Parameters for Arabic
|
||||
|
||||
When initializing the Ollama LLM with Qwen3-8B for Arabic, use these optimized parameters:
|
||||
|
||||
```python
|
||||
def get_ollama_llm():
|
||||
"""
|
||||
Initialize and return an Ollama LLM instance configured for Arabic support with Qwen3-8B.
|
||||
"""
|
||||
try:
|
||||
# Get settings from Django settings or use defaults
|
||||
base_url = getattr(settings, 'OLLAMA_BASE_URL', 'http://10.10.1.132:11434')
|
||||
model = getattr(settings, 'OLLAMA_MODEL', 'qwen3:8b')
|
||||
timeout = getattr(settings, 'OLLAMA_TIMEOUT', 120)
|
||||
|
||||
# Configure Ollama with parameters optimized for Qwen3-8B with Arabic
|
||||
return Ollama(
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
timeout=timeout,
|
||||
# Parameters optimized for Qwen3-8B with Arabic
|
||||
parameters={
|
||||
"temperature": 0.2, # Lower temperature for more deterministic outputs
|
||||
"top_p": 0.8, # Slightly reduced for more focused responses
|
||||
"top_k": 40, # Standard value works well with Qwen3
|
||||
"num_ctx": 4096, # Qwen3 supports larger context windows
|
||||
"num_predict": 2048, # Maximum tokens to generate
|
||||
"stop": ["```", "</s>"], # Stop sequences for JSON generation
|
||||
"repeat_penalty": 1.1 # Slight penalty to avoid repetition
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing Ollama LLM: {str(e)}")
|
||||
return None
|
||||
```
|
||||
|
||||
## Prompt Template Optimization for Qwen3-8B
|
||||
|
||||
Qwen3-8B responds well to clear, structured prompts. For Arabic analysis, use this optimized template:
|
||||
|
||||
```python
|
||||
def create_prompt_analyzer_chain(language='ar'):
|
||||
"""
|
||||
Create a LangChain for analyzing prompts in Arabic with Qwen3-8B.
|
||||
"""
|
||||
llm = get_ollama_llm()
|
||||
if not llm:
|
||||
return None
|
||||
|
||||
# Define the prompt template optimized for Qwen3-8B
|
||||
if language == 'ar':
|
||||
template = """
|
||||
أنت مساعد ذكي متخصص في تحليل نماذج Django. مهمتك هي تحليل الاستعلام التالي وتحديد:
|
||||
1. نوع التحليل المطلوب
|
||||
2. نماذج البيانات المستهدفة
|
||||
3. أي معلمات استعلام
|
||||
|
||||
الاستعلام: {prompt}
|
||||
|
||||
قم بتقديم إجابتك بتنسيق JSON فقط، بدون أي نص إضافي، كما يلي:
|
||||
```json
|
||||
{{
|
||||
"analysis_type": "count" أو "relationship" أو "performance" أو "statistics" أو "general",
|
||||
"target_models": ["ModelName1", "ModelName2"],
|
||||
"query_params": {{"field1": "value1", "field2": "value2"}}
|
||||
}}
|
||||
```
|
||||
"""
|
||||
else:
|
||||
template = """
|
||||
You are an intelligent assistant specialized in analyzing Django models. Your task is to analyze the following prompt and determine:
|
||||
1. The type of analysis required
|
||||
2. Target data models
|
||||
3. Any query parameters
|
||||
|
||||
Prompt: {prompt}
|
||||
|
||||
Provide your answer in JSON format only, without any additional text, as follows:
|
||||
```json
|
||||
{
|
||||
"analysis_type": "count" or "relationship" or "performance" or "statistics" or "general",
|
||||
"target_models": ["ModelName1", "ModelName2"],
|
||||
"query_params": {"field1": "value1", "field2": "value2"}
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
# Create the prompt template
|
||||
prompt_template = PromptTemplate(
|
||||
input_variables=["prompt"],
|
||||
template=template
|
||||
)
|
||||
|
||||
# Create and return the LLM chain
|
||||
return LLMChain(llm=llm, prompt=prompt_template)
|
||||
```
|
||||
|
||||
## Improved JSON Parsing for Qwen3-8B Responses
|
||||
|
||||
Qwen3-8B sometimes includes markdown formatting in its JSON responses. Use this improved parsing function:
|
||||
|
||||
```python
|
||||
def _parse_llm_json_response(result):
|
||||
"""
|
||||
Parse JSON from Qwen3-8B response, handling markdown formatting.
|
||||
"""
|
||||
try:
|
||||
# First try to extract JSON from markdown code blocks
|
||||
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', result)
|
||||
if json_match:
|
||||
json_str = json_match.group(1).strip()
|
||||
return json.loads(json_str)
|
||||
|
||||
# If no markdown blocks, try to find JSON object directly
|
||||
json_match = re.search(r'({[\s\S]*})', result)
|
||||
if json_match:
|
||||
json_str = json_match.group(1).strip()
|
||||
return json.loads(json_str)
|
||||
|
||||
# If still no match, try to parse the entire response as JSON
|
||||
return json.loads(result.strip())
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse JSON from LLM response: {str(e)}")
|
||||
return None
|
||||
```
|
||||
|
||||
## Performance Considerations for Qwen3-8B
|
||||
|
||||
- **Memory Usage**: Qwen3-8B typically requires 8-16GB of RAM when running on Ollama
|
||||
- **First Request Latency**: The first request may take 5-10 seconds as the model loads
|
||||
- **Subsequent Requests**: Typically respond within 1-3 seconds
|
||||
- **Batch Processing**: Consider batching multiple analyses for efficiency
|
||||
|
||||
## Handling Arabic-Specific Challenges with Qwen3-8B
|
||||
|
||||
1. **Diacritics**: Qwen3-8B handles Arabic diacritics well, but for consistency, consider normalizing input by removing diacritics
|
||||
|
||||
2. **Text Direction**: When displaying results in frontend, ensure proper RTL (right-to-left) support
|
||||
|
||||
3. **Dialectal Variations**: Qwen3-8B performs best with Modern Standard Arabic (MSA), but has reasonable support for major dialects
|
||||
|
||||
4. **Technical Terms**: For Django-specific technical terms, consider providing a glossary in both English and Arabic
|
||||
|
||||
## Example Arabic Prompts Optimized for Qwen3-8B
|
||||
|
||||
```
|
||||
# Count query
|
||||
كم عدد السيارات المتوفرة في النظام؟
|
||||
|
||||
# Relationship analysis
|
||||
ما هي العلاقة بين نموذج المستخدم ونموذج الطلب؟
|
||||
|
||||
# Performance analysis
|
||||
حدد مشاكل الأداء المحتملة في نموذج المنتج
|
||||
|
||||
# Statistical analysis
|
||||
ما هو متوسط سعر السيارات المتوفرة؟
|
||||
```
|
||||
|
||||
## Troubleshooting Qwen3-8B Specific Issues
|
||||
|
||||
1. **Incomplete JSON**: If Qwen3-8B returns incomplete JSON, try:
|
||||
- Reducing the complexity of your prompt
|
||||
- Lowering the temperature parameter to 0.1
|
||||
- Adding explicit JSON formatting instructions
|
||||
|
||||
2. **Arabic Character Encoding**: If you see garbled Arabic text, ensure:
|
||||
- Your database uses UTF-8 encoding
|
||||
- All HTTP responses include proper content-type headers
|
||||
- Frontend properly handles Arabic character rendering
|
||||
|
||||
3. **Slow Response Times**: If responses are slow:
|
||||
- Consider using the quantized version: `qwen3:8b-q4_0`
|
||||
- Reduce context window size if full 4096 context isn't needed
|
||||
- Implement more aggressive caching
|
||||
|
||||
## Conclusion
|
||||
|
||||
Qwen3-8B is an excellent choice for Arabic language support in your Django AI Analyst application. With these optimized settings and techniques, you'll get reliable performance for analyzing Django models through Arabic natural language prompts.
|
||||
@ -1,163 +0,0 @@
|
||||
# Django AI Analyst - README
|
||||
|
||||
This package provides a Django application that enables AI-powered analysis of Django models through natural language prompts. The AI agent can analyze model structures, relationships, and data to provide insights in JSON format.
|
||||
|
||||
## Features
|
||||
|
||||
- Natural language prompt processing for model analysis
|
||||
- Support for various types of insights:
|
||||
- Count queries (e.g., "How many cars do we have?")
|
||||
- Relationship analysis between models
|
||||
- Performance optimization suggestions
|
||||
- Statistical analysis of model fields
|
||||
- General model structure analysis
|
||||
- Dealer-specific data access controls
|
||||
- Caching mechanism for improved performance
|
||||
- Visualization data generation for frontend display
|
||||
- Comprehensive test suite
|
||||
|
||||
## Installation
|
||||
|
||||
1. Add 'ai_analyst' to your INSTALLED_APPS setting:
|
||||
|
||||
```python
|
||||
INSTALLED_APPS = [
|
||||
...
|
||||
'ai_analyst',
|
||||
]
|
||||
```
|
||||
|
||||
2. Include the ai_analyst URLconf in your project urls.py:
|
||||
|
||||
```python
|
||||
path('api/ai/', include('ai_analyst.urls')),
|
||||
```
|
||||
|
||||
3. Run migrations to create the AnalysisCache model:
|
||||
|
||||
```bash
|
||||
python manage.py makemigrations ai_analyst
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Send POST requests to the `/api/ai/analyze/` endpoint with a JSON body containing:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "How many cars do we have?",
|
||||
"dealer_id": 1 // Optional, for dealer-specific queries
|
||||
}
|
||||
```
|
||||
|
||||
The response will be a JSON object with insights based on the prompt:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"request_id": "a1b2c3d4",
|
||||
"timestamp": "2025-05-25T23:21:56Z",
|
||||
"prompt": "How many cars do we have?",
|
||||
"insights": [
|
||||
{
|
||||
"type": "count_analysis",
|
||||
"results": [
|
||||
{
|
||||
"model": "Car",
|
||||
"count": 42,
|
||||
"filters_applied": {}
|
||||
}
|
||||
],
|
||||
"visualization_data": {
|
||||
"chart_type": "bar",
|
||||
"labels": ["Car"],
|
||||
"data": [42]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
### Cache Duration
|
||||
|
||||
You can customize the cache duration by setting the `CACHE_DURATION` class variable in the `ModelAnalystView` class:
|
||||
|
||||
```python
|
||||
# In your settings.py
|
||||
AI_ANALYST_CACHE_DURATION = 7200 # 2 hours in seconds
|
||||
|
||||
# Then in views.py
|
||||
class ModelAnalystView(View):
|
||||
CACHE_DURATION = getattr(settings, 'AI_ANALYST_CACHE_DURATION', 3600)
|
||||
# ...
|
||||
```
|
||||
|
||||
### Permission Logic
|
||||
|
||||
The `_check_permissions` method in `ModelAnalystView` can be customized to match your application's permission model:
|
||||
|
||||
```python
|
||||
def _check_permissions(self, user, dealer_id):
|
||||
# Your custom permission logic here
|
||||
return user.has_perm('ai_analyst.can_analyze_models')
|
||||
```
|
||||
|
||||
## Example Prompts
|
||||
|
||||
- "How many cars do we have?"
|
||||
- "Show relationship between User and Order"
|
||||
- "What is the average price of products?"
|
||||
- "Count active users"
|
||||
- "Identify performance issues in the Order model"
|
||||
- "Show maximum age of customers"
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
The JSON responses include visualization_data that can be used with charting libraries like Chart.js:
|
||||
|
||||
```javascript
|
||||
// Example with Chart.js
|
||||
fetch('/api/ai/analyze/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: 'How many cars do we have?',
|
||||
dealer_id: 1
|
||||
}),
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success' && data.insights.length > 0) {
|
||||
const insight = data.insights[0];
|
||||
const vizData = insight.visualization_data;
|
||||
|
||||
const ctx = document.getElementById('insightChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: vizData.chart_type,
|
||||
data: {
|
||||
labels: vizData.labels,
|
||||
datasets: [{
|
||||
label: insight.type,
|
||||
data: vizData.data,
|
||||
backgroundColor: [
|
||||
'rgba(255, 99, 132, 0.2)',
|
||||
'rgba(54, 162, 235, 0.2)',
|
||||
'rgba(255, 206, 86, 0.2)'
|
||||
],
|
||||
borderColor: [
|
||||
'rgba(255, 99, 132, 1)',
|
||||
'rgba(54, 162, 235, 1)',
|
||||
'rgba(255, 206, 86, 1)'
|
||||
],
|
||||
borderWidth: 1
|
||||
}]
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
788
haikalbot/ai_agent.py
Normal file
788
haikalbot/ai_agent.py
Normal file
@ -0,0 +1,788 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Dict, Optional, Any, Union
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.db.models import QuerySet, Q, F, Value, CharField, Sum, Avg, Count, Max, Min
|
||||
from django.db.models.functions import Concat, Cast
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.core.serializers import serialize
|
||||
from django.conf import settings
|
||||
from langchain_ollama import ChatOllama
|
||||
from langchain_core.messages import SystemMessage, HumanMessage
|
||||
import json
|
||||
import re
|
||||
import logging
|
||||
from functools import reduce
|
||||
import operator
|
||||
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration settings
|
||||
LLM_MODEL = getattr(settings, 'MODEL_ANALYZER_LLM_MODEL', 'qwen:7b-chat')
|
||||
LLM_TEMPERATURE = getattr(settings, 'MODEL_ANALYZER_LLM_TEMPERATURE', 0.3)
|
||||
LLM_MAX_TOKENS = getattr(settings, 'MODEL_ANALYZER_LLM_MAX_TOKENS', 2048)
|
||||
CACHE_TIMEOUT = getattr(settings, 'MODEL_ANALYZER_CACHE_TIMEOUT', 3600)
|
||||
|
||||
system_instruction = """
|
||||
You are a specialized AI agent designed to analyze Django models and extract relevant information based on user input in Arabic or English. You must:
|
||||
|
||||
1. Model Analysis:
|
||||
- Parse the user's natural language prompt to understand the analysis requirements
|
||||
- Identify the relevant Django model(s) from the provided model structure
|
||||
- Extract only the fields needed for the specific analysis
|
||||
- Handle both direct fields and relationship fields appropriately
|
||||
|
||||
2. Field Selection:
|
||||
- Determine relevant fields based on:
|
||||
* Analysis type (count, average, sum, etc.)
|
||||
* Explicit field mentions in the prompt
|
||||
* Related fields needed for joins
|
||||
* Common fields for the requested analysis type
|
||||
|
||||
3. Return Structure:
|
||||
Return a JSON response with:
|
||||
{
|
||||
"status": "success",
|
||||
"analysis_requirements": {
|
||||
"app_label": "<django_app_name>",
|
||||
"model_name": "<model_name>",
|
||||
"fields": ["field1", "field2", ...],
|
||||
"relationships": [{"field": "related_field", "type": "relation_type", "to": "related_model"}]
|
||||
},
|
||||
"language": "<ar|en>"
|
||||
}
|
||||
|
||||
4. Analysis Types:
|
||||
- COUNT queries: Return id field
|
||||
- AGGREGATE queries (avg, sum): Return numeric fields
|
||||
- DATE queries: Return date/timestamp fields
|
||||
- RELATIONSHIP queries: Return foreign key and related fields
|
||||
- TEXT queries: Return relevant text fields
|
||||
|
||||
5. Special Considerations:
|
||||
- Handle both Arabic and English inputs
|
||||
- Consider model relationships for joined queries
|
||||
- Include only fields necessary for the requested analysis
|
||||
- Support filtering and grouping requirements
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FieldAnalysis:
|
||||
name: str
|
||||
field_type: str
|
||||
is_required: bool
|
||||
is_relation: bool
|
||||
related_model: Optional[str] = None
|
||||
analysis_relevance: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelAnalysis:
|
||||
app_label: str
|
||||
model_name: str
|
||||
relevant_fields: List[FieldAnalysis]
|
||||
relationships: List[Dict[str, str]]
|
||||
confidence_score: float
|
||||
|
||||
|
||||
class DjangoModelAnalyzer:
|
||||
def __init__(self):
|
||||
self.analysis_patterns = {
|
||||
'count': {
|
||||
'patterns': [r'\b(count|number|how many)\b'],
|
||||
'fields': ['id'],
|
||||
'weight': 1.0
|
||||
},
|
||||
'aggregate': {
|
||||
'patterns': [r'\b(average|avg|mean|sum|total)\b'],
|
||||
'fields': ['price', 'amount', 'value', 'cost', 'quantity'],
|
||||
'weight': 0.8
|
||||
},
|
||||
'temporal': {
|
||||
'patterns': [r'\b(date|time|when|period)\b'],
|
||||
'fields': ['created_at', 'updated_at', 'date', 'timestamp'],
|
||||
'weight': 0.7
|
||||
}
|
||||
}
|
||||
|
||||
def analyze_prompt(self, prompt: str, model_structure: List) -> ModelAnalysis:
|
||||
# Initialize LLM
|
||||
llm = ChatOllama(
|
||||
model=LLM_MODEL,
|
||||
temperature=LLM_TEMPERATURE
|
||||
)
|
||||
|
||||
# Get model analysis from LLM
|
||||
messages = [
|
||||
SystemMessage(content=system_instruction),
|
||||
HumanMessage(content=prompt)
|
||||
]
|
||||
|
||||
try:
|
||||
response = llm.invoke(messages)
|
||||
|
||||
if not response or not hasattr(response, 'content') or response.content is None:
|
||||
raise ValueError("Empty response from LLM")
|
||||
|
||||
analysis_requirements = self._parse_llm_response(response.content)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in LLM analysis: {e}")
|
||||
analysis_requirements = self._pattern_based_analysis(prompt, model_structure)
|
||||
|
||||
return self._enhance_analysis(analysis_requirements, model_structure)
|
||||
|
||||
def _parse_llm_response(self, response: str) -> Dict:
|
||||
try:
|
||||
json_match = re.search(r'({.*})', response.replace('\n', ' '), re.DOTALL)
|
||||
if json_match:
|
||||
return json.loads(json_match.group(1))
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing LLM response: {e}")
|
||||
return {}
|
||||
|
||||
def _pattern_based_analysis(self, prompt: str, model_structure: List) -> Dict:
|
||||
analysis_type = None
|
||||
relevant_fields = []
|
||||
|
||||
for analysis_name, config in self.analysis_patterns.items():
|
||||
for pattern in config['patterns']:
|
||||
if re.search(pattern, prompt.lower()):
|
||||
relevant_fields.extend(config['fields'])
|
||||
analysis_type = analysis_name
|
||||
break
|
||||
if analysis_type:
|
||||
break
|
||||
|
||||
return {
|
||||
'analysis_type': analysis_type or 'basic',
|
||||
'fields': list(set(relevant_fields)) or ['id', 'name']
|
||||
}
|
||||
|
||||
def _enhance_analysis(self, requirements: Dict, model_structure: List) -> ModelAnalysis:
|
||||
app_label = requirements.get("analysis_requirements", {}).get("app_label")
|
||||
model_name = requirements.get("analysis_requirements", {}).get("model_name")
|
||||
fields = requirements.get("analysis_requirements", {}).get("fields") or []
|
||||
|
||||
if not isinstance(fields, list):
|
||||
raise ValueError(f"Invalid fields in analysis requirements: {fields}")
|
||||
|
||||
try:
|
||||
model = apps.get_model(app_label, model_name)
|
||||
except LookupError as e:
|
||||
logger.error(f"Model lookup error: {e}")
|
||||
return None
|
||||
|
||||
relevant_fields = []
|
||||
relationships = []
|
||||
|
||||
for field_name in fields:
|
||||
try:
|
||||
field = model._meta.get_field(field_name)
|
||||
field_analysis = FieldAnalysis(
|
||||
name=field_name,
|
||||
field_type=field.get_internal_type(),
|
||||
is_required=not field.null if hasattr(field, 'null') else True,
|
||||
is_relation=field.is_relation,
|
||||
related_model=field.related_model.__name__ if field.is_relation and hasattr(field,
|
||||
'related_model') and field.related_model else None
|
||||
)
|
||||
|
||||
field_analysis.analysis_relevance = self._calculate_field_relevance(
|
||||
field_analysis,
|
||||
requirements.get('analysis_type', 'basic')
|
||||
)
|
||||
|
||||
relevant_fields.append(field_analysis)
|
||||
|
||||
if field.is_relation:
|
||||
relationships.append({
|
||||
'field': field_name,
|
||||
'type': field.get_internal_type(),
|
||||
'to': field.related_model.__name__ if hasattr(field,
|
||||
'related_model') and field.related_model else ''
|
||||
})
|
||||
|
||||
except FieldDoesNotExist:
|
||||
logger.warning(f"Field {field_name} not found in {model_name}")
|
||||
|
||||
return ModelAnalysis(
|
||||
app_label=app_label,
|
||||
model_name=model_name,
|
||||
relevant_fields=sorted(relevant_fields, key=lambda x: x.analysis_relevance, reverse=True),
|
||||
relationships=relationships,
|
||||
confidence_score=self._calculate_confidence_score(relevant_fields)
|
||||
)
|
||||
|
||||
def _calculate_field_relevance(self, field: FieldAnalysis, analysis_type: str) -> float:
|
||||
base_score = 0.5
|
||||
if analysis_type in self.analysis_patterns:
|
||||
if field.name in self.analysis_patterns[analysis_type]['fields']:
|
||||
base_score += self.analysis_patterns[analysis_type]['weight']
|
||||
if field.is_required:
|
||||
base_score += 0.2
|
||||
if field.is_relation:
|
||||
base_score += 0.1
|
||||
return min(base_score, 1.0)
|
||||
|
||||
def _calculate_confidence_score(self, fields: List[FieldAnalysis]) -> float:
|
||||
if not fields:
|
||||
return 0.0
|
||||
return sum(field.analysis_relevance for field in fields) / len(fields)
|
||||
|
||||
|
||||
def get_all_model_structures(filtered_apps: Optional[List[str]] = None) -> List[Dict]:
|
||||
"""
|
||||
Retrieve structure information for all Django models, optionally filtered by app names.
|
||||
|
||||
Args:
|
||||
filtered_apps: Optional list of app names to filter models by
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing model structure information
|
||||
"""
|
||||
structures = []
|
||||
for model in apps.get_models():
|
||||
app_label = model._meta.app_label
|
||||
if filtered_apps and app_label not in filtered_apps:
|
||||
continue
|
||||
|
||||
fields = {}
|
||||
relationships = []
|
||||
|
||||
for field in model._meta.get_fields():
|
||||
if field.is_relation:
|
||||
# Get related model name safely
|
||||
related_model_name = None
|
||||
if hasattr(field, 'related_model') and field.related_model:
|
||||
related_model_name = field.related_model.__name__
|
||||
elif hasattr(field, 'model') and field.model:
|
||||
related_model_name = field.model.__name__
|
||||
|
||||
if related_model_name: # Only add relationship if we have a valid related model
|
||||
relationships.append({
|
||||
"field": field.name,
|
||||
"type": field.get_internal_type(),
|
||||
"to": related_model_name
|
||||
})
|
||||
else:
|
||||
fields[field.name] = field.get_internal_type()
|
||||
|
||||
structures.append({
|
||||
"app_label": app_label,
|
||||
"model_name": model.__name__,
|
||||
"fields": fields,
|
||||
"relationships": relationships
|
||||
})
|
||||
|
||||
return structures
|
||||
|
||||
|
||||
def apply_joins(queryset: QuerySet, joins: List[Dict[str, str]]) -> QuerySet:
|
||||
"""
|
||||
Apply joins to the queryset based on the provided join specifications.
|
||||
|
||||
Args:
|
||||
queryset: The base queryset to apply joins to
|
||||
joins: List of join specifications with path and type
|
||||
|
||||
Returns:
|
||||
Updated queryset with joins applied
|
||||
"""
|
||||
if not joins:
|
||||
return queryset
|
||||
|
||||
for join in joins:
|
||||
path = join.get("path")
|
||||
join_type = join.get("type", "LEFT").upper()
|
||||
|
||||
if not path:
|
||||
continue
|
||||
|
||||
try:
|
||||
if join_type == "LEFT":
|
||||
queryset = queryset.select_related(path)
|
||||
else:
|
||||
queryset = queryset.prefetch_related(path)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to apply join for {path}: {e}")
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
def apply_filters(queryset: QuerySet, filters: Dict[str, Any]) -> QuerySet:
|
||||
"""
|
||||
Apply filters to queryset with advanced filter operations.
|
||||
|
||||
Args:
|
||||
queryset: The base queryset to apply filters to
|
||||
filters: Dictionary of field:value pairs or complex filter operations
|
||||
|
||||
Returns:
|
||||
Filtered queryset
|
||||
"""
|
||||
if not filters:
|
||||
return queryset
|
||||
|
||||
q_objects = []
|
||||
|
||||
for key, value in filters.items():
|
||||
if isinstance(value, dict):
|
||||
# Handle complex filters
|
||||
operation = value.get('operation', 'exact')
|
||||
filter_value = value.get('value')
|
||||
|
||||
if not filter_value and operation != 'isnull':
|
||||
continue
|
||||
|
||||
if operation == 'contains':
|
||||
q_objects.append(Q(**{f"{key}__icontains": filter_value}))
|
||||
elif operation == 'in':
|
||||
if isinstance(filter_value, list) and filter_value:
|
||||
q_objects.append(Q(**{f"{key}__in": filter_value}))
|
||||
elif operation in ['gt', 'gte', 'lt', 'lte', 'exact', 'iexact', 'startswith', 'endswith']:
|
||||
q_objects.append(Q(**{f"{key}__{operation}": filter_value}))
|
||||
elif operation == 'between' and isinstance(filter_value, list) and len(filter_value) >= 2:
|
||||
q_objects.append(Q(**{
|
||||
f"{key}__gte": filter_value[0],
|
||||
f"{key}__lte": filter_value[1]
|
||||
}))
|
||||
elif operation == 'isnull':
|
||||
q_objects.append(Q(**{f"{key}__isnull": bool(filter_value)}))
|
||||
else:
|
||||
# Simple exact match
|
||||
q_objects.append(Q(**{key: value}))
|
||||
|
||||
if not q_objects:
|
||||
return queryset
|
||||
|
||||
return queryset.filter(reduce(operator.and_, q_objects))
|
||||
|
||||
|
||||
def process_aggregation(
|
||||
queryset: QuerySet,
|
||||
aggregation: str,
|
||||
fields: List[str],
|
||||
group_by: Optional[List[str]] = None
|
||||
) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
|
||||
"""
|
||||
Process aggregation queries with support for grouping.
|
||||
|
||||
Args:
|
||||
queryset: The base queryset to aggregate
|
||||
aggregation: Aggregation type (sum, avg, count, max, min)
|
||||
fields: Fields to aggregate
|
||||
group_by: Optional fields to group by
|
||||
|
||||
Returns:
|
||||
Dictionary of aggregation results or list of grouped results
|
||||
"""
|
||||
if not fields:
|
||||
return {"error": "No fields specified for aggregation"}
|
||||
|
||||
agg_func_map = {
|
||||
"sum": Sum,
|
||||
"avg": Avg,
|
||||
"count": Count,
|
||||
"max": Max,
|
||||
"min": Min
|
||||
}
|
||||
|
||||
agg_func = agg_func_map.get(aggregation.lower())
|
||||
if not agg_func:
|
||||
return {"error": f"Unsupported aggregation: {aggregation}"}
|
||||
|
||||
try:
|
||||
if group_by:
|
||||
# Create aggregation dictionary for valid fields
|
||||
agg_dict = {}
|
||||
for field in fields:
|
||||
if field not in group_by:
|
||||
agg_dict[f"{aggregation}_{field}"] = agg_func(field)
|
||||
|
||||
if not agg_dict:
|
||||
return {"error": "No valid fields for aggregation after excluding group_by fields"}
|
||||
|
||||
# Apply group_by and aggregation
|
||||
return list(queryset.values(*group_by).annotate(**agg_dict))
|
||||
else:
|
||||
# Simple aggregation without grouping
|
||||
return queryset.aggregate(**{
|
||||
f"{aggregation}_{field}": agg_func(field)
|
||||
for field in fields
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Aggregation error: {e}")
|
||||
return {"error": f"Aggregation failed: {str(e)}"}
|
||||
|
||||
|
||||
def prepare_chart_data(data: List[Dict], fields: List[str], chart_type: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Prepare data for chart visualization.
|
||||
|
||||
Args:
|
||||
data: List of data dictionaries
|
||||
fields: Fields to include in the chart
|
||||
chart_type: Type of chart (pie, bar, line)
|
||||
|
||||
Returns:
|
||||
Dictionary with chart configuration
|
||||
"""
|
||||
if not data or not fields or len(fields) < 1 or not chart_type:
|
||||
return None
|
||||
|
||||
# Validate chart type
|
||||
chart_type = chart_type.lower()
|
||||
if chart_type not in ["pie", "bar", "line", "doughnut", "radar", "scatter"]:
|
||||
chart_type = "bar" # Default to bar chart for unsupported types
|
||||
|
||||
try:
|
||||
# For aggregation results that come as a dictionary
|
||||
if isinstance(data, dict):
|
||||
# Convert single dict to list for chart processing
|
||||
labels = list(data.keys())
|
||||
values = list(data.values())
|
||||
|
||||
return {
|
||||
"type": chart_type,
|
||||
"labels": [str(label).replace(f"{fields[0]}_", "") for label in labels],
|
||||
"data": [float(value) if isinstance(value, (int, float)) else 0 for value in values],
|
||||
"backgroundColor": [
|
||||
"rgba(54, 162, 235, 0.6)",
|
||||
"rgba(255, 99, 132, 0.6)",
|
||||
"rgba(255, 206, 86, 0.6)",
|
||||
"rgba(75, 192, 192, 0.6)",
|
||||
"rgba(153, 102, 255, 0.6)",
|
||||
"rgba(255, 159, 64, 0.6)"
|
||||
]
|
||||
}
|
||||
|
||||
# For regular query results as list of dictionaries
|
||||
# Create labels from first field values
|
||||
labels = [str(item.get(fields[0], "")) for item in data]
|
||||
|
||||
if chart_type == "pie" or chart_type == "doughnut":
|
||||
# For pie charts, we need just one data series
|
||||
data_values = []
|
||||
for item in data:
|
||||
# Use second field for values if available, otherwise use 1
|
||||
if len(fields) > 1:
|
||||
try:
|
||||
value = float(item.get(fields[1], 0))
|
||||
except (ValueError, TypeError):
|
||||
value = 0
|
||||
data_values.append(value)
|
||||
else:
|
||||
data_values.append(1) # Default count if no value field
|
||||
|
||||
return {
|
||||
"type": chart_type,
|
||||
"labels": labels,
|
||||
"data": data_values,
|
||||
"backgroundColor": [
|
||||
"rgba(54, 162, 235, 0.6)",
|
||||
"rgba(255, 99, 132, 0.6)",
|
||||
"rgba(255, 206, 86, 0.6)",
|
||||
"rgba(75, 192, 192, 0.6)",
|
||||
"rgba(153, 102, 255, 0.6)",
|
||||
"rgba(255, 159, 64, 0.6)"
|
||||
] * (len(data_values) // 6 + 1) # Repeat colors as needed
|
||||
}
|
||||
else:
|
||||
# For other charts, create dataset for each field after the first
|
||||
datasets = []
|
||||
for i, field in enumerate(fields[1:], 1):
|
||||
try:
|
||||
dataset = {
|
||||
"label": field,
|
||||
"data": [float(item.get(field, 0) or 0) for item in data],
|
||||
"backgroundColor": f"rgba({50 + i * 50}, {100 + i * 40}, 235, 0.6)",
|
||||
"borderColor": f"rgba({50 + i * 50}, {100 + i * 40}, 235, 1.0)",
|
||||
"borderWidth": 1
|
||||
}
|
||||
datasets.append(dataset)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Error processing field {field} for chart: {e}")
|
||||
|
||||
return {
|
||||
"type": chart_type,
|
||||
"labels": labels,
|
||||
"datasets": datasets
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error preparing chart data: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def query_django_model(parsed: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute Django model queries based on parsed analysis requirements.
|
||||
|
||||
Args:
|
||||
parsed: Dictionary containing query parameters:
|
||||
- app_label: Django app label
|
||||
- model: Model name
|
||||
- fields: List of fields to query
|
||||
- filters: Query filters
|
||||
- aggregation: Aggregation type
|
||||
- chart: Chart type for visualization
|
||||
- joins: List of joins to apply
|
||||
- group_by: Fields to group by
|
||||
- order_by: Fields to order by
|
||||
- limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
Dictionary with query results
|
||||
"""
|
||||
try:
|
||||
# Extract parameters with defaults
|
||||
app_label = parsed.get("app_label")
|
||||
model_name = parsed.get("model_name")
|
||||
fields = parsed.get("fields", [])
|
||||
filters = parsed.get("filters", {})
|
||||
aggregation = parsed.get("aggregation")
|
||||
chart = parsed.get("chart")
|
||||
joins = parsed.get("joins", [])
|
||||
group_by = parsed.get("group_by", [])
|
||||
order_by = parsed.get("order_by", [])
|
||||
limit = int(parsed.get("limit", 1000))
|
||||
language = parsed.get("language", "en")
|
||||
|
||||
# Validate required parameters
|
||||
if not app_label or not model_name:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "app_label and model are required",
|
||||
"language": language
|
||||
}
|
||||
|
||||
# Get model class
|
||||
try:
|
||||
model = apps.get_model(app_label=app_label, model_name=model_name)
|
||||
except LookupError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"Model '{model_name}' not found in app '{app_label}'",
|
||||
"language": language
|
||||
}
|
||||
|
||||
# Validate fields against model
|
||||
if fields:
|
||||
model_fields = [f.name for f in model._meta.fields]
|
||||
invalid_fields = [f for f in fields if f not in model_fields]
|
||||
if invalid_fields:
|
||||
logger.warning(f"Invalid fields requested: {invalid_fields}")
|
||||
fields = [f for f in fields if f in model_fields]
|
||||
|
||||
# Build queryset
|
||||
queryset = model.objects.all()
|
||||
|
||||
# Apply joins
|
||||
queryset = apply_joins(queryset, joins)
|
||||
|
||||
# Apply filters
|
||||
if filters:
|
||||
try:
|
||||
queryset = apply_filters(queryset, filters)
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying filters: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"Invalid filters: {str(e)}",
|
||||
"language": language
|
||||
}
|
||||
|
||||
# Handle aggregations
|
||||
if aggregation:
|
||||
result = process_aggregation(queryset, aggregation, fields, group_by)
|
||||
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": result["error"],
|
||||
"language": language
|
||||
}
|
||||
|
||||
chart_data = None
|
||||
if chart:
|
||||
chart_data = prepare_chart_data(result, fields, chart)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": result,
|
||||
"chart": chart_data,
|
||||
"language": language
|
||||
}
|
||||
|
||||
# Handle regular queries
|
||||
try:
|
||||
# Apply field selection
|
||||
if fields:
|
||||
queryset = queryset.values(*fields)
|
||||
|
||||
# Apply ordering
|
||||
if order_by:
|
||||
queryset = queryset.order_by(*order_by)
|
||||
|
||||
# Apply limit (with safety check)
|
||||
if limit <= 0:
|
||||
limit = 1000
|
||||
queryset = queryset[:limit]
|
||||
|
||||
# Convert queryset to list
|
||||
data = list(queryset)
|
||||
|
||||
# Prepare chart data if needed
|
||||
chart_data = None
|
||||
if chart and data and fields:
|
||||
chart_data = prepare_chart_data(data, fields, chart)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": data,
|
||||
"count": len(data),
|
||||
"chart": chart_data,
|
||||
"metadata": {
|
||||
"total_count": len(data),
|
||||
"fields": fields,
|
||||
"model": model_name,
|
||||
"app": app_label
|
||||
},
|
||||
"language": language
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing query: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"Query execution failed: {str(e)}",
|
||||
"language": language
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in query_django_model: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"Unexpected error: {str(e)}",
|
||||
"language": parsed.get("language", "en")
|
||||
}
|
||||
|
||||
|
||||
def determine_aggregation_type(prompt: str, fields: List[FieldAnalysis]) -> Optional[str]:
|
||||
"""
|
||||
Determine the appropriate aggregation type based on the prompt and fields.
|
||||
|
||||
Args:
|
||||
prompt: User prompt text
|
||||
fields: List of field analysis objects
|
||||
|
||||
Returns:
|
||||
Aggregation type or None
|
||||
"""
|
||||
if any(pattern in prompt.lower() for pattern in ['average', 'avg', 'mean', 'معدل', 'متوسط']):
|
||||
return 'avg'
|
||||
elif any(pattern in prompt.lower() for pattern in ['sum', 'total', 'مجموع', 'إجمالي']):
|
||||
return 'sum'
|
||||
elif any(pattern in prompt.lower() for pattern in ['count', 'number', 'how many', 'عدد', 'كم']):
|
||||
return 'count'
|
||||
elif any(pattern in prompt.lower() for pattern in ['maximum', 'max', 'highest', 'أقصى', 'أعلى']):
|
||||
return 'max'
|
||||
elif any(pattern in prompt.lower() for pattern in ['minimum', 'min', 'lowest', 'أدنى', 'أقل']):
|
||||
return 'min'
|
||||
|
||||
# Check field types for numeric fields to determine default aggregation
|
||||
numeric_fields = [field for field in fields if field.field_type in ['DecimalField', 'FloatField', 'IntegerField']]
|
||||
if numeric_fields:
|
||||
return 'sum' # Default to sum for numeric fields
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def determine_chart_type(prompt: str, fields: List[FieldAnalysis]) -> Optional[str]:
|
||||
"""
|
||||
Determine the appropriate chart type based on the prompt and fields.
|
||||
|
||||
Args:
|
||||
prompt: User prompt text
|
||||
fields: List of field analysis objects
|
||||
|
||||
Returns:
|
||||
Chart type or None
|
||||
"""
|
||||
# Check for explicit chart type mentions in prompt
|
||||
if any(term in prompt.lower() for term in ['line chart', 'time series', 'trend', 'رسم خطي', 'اتجاه']):
|
||||
return 'line'
|
||||
elif any(term in prompt.lower() for term in ['bar chart', 'histogram', 'column', 'رسم شريطي', 'أعمدة']):
|
||||
return 'bar'
|
||||
elif any(term in prompt.lower() for term in ['pie chart', 'circle chart', 'رسم دائري', 'فطيرة']):
|
||||
return 'pie'
|
||||
elif any(term in prompt.lower() for term in ['doughnut', 'دونات']):
|
||||
return 'doughnut'
|
||||
elif any(term in prompt.lower() for term in ['radar', 'spider', 'رادار']):
|
||||
return 'radar'
|
||||
|
||||
# Determine chart type based on field types and count
|
||||
date_fields = [field for field in fields if field.field_type in ['DateField', 'DateTimeField']]
|
||||
numeric_fields = [field for field in fields if field.field_type in ['DecimalField', 'FloatField', 'IntegerField']]
|
||||
|
||||
if date_fields and numeric_fields:
|
||||
return 'line' # Time series data
|
||||
elif len(fields) == 2 and len(numeric_fields) >= 1:
|
||||
return 'bar' # Category and value
|
||||
elif len(fields) == 1 or (len(fields) == 2 and len(numeric_fields) == 1):
|
||||
return 'pie' # Single dimension data
|
||||
elif len(fields) > 2:
|
||||
return 'bar' # Multi-dimensional data
|
||||
|
||||
# Default
|
||||
return 'bar'
|
||||
|
||||
|
||||
def analyze_prompt(prompt: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze a natural language prompt and execute the appropriate Django model query.
|
||||
|
||||
Args:
|
||||
prompt: Natural language prompt from user
|
||||
|
||||
Returns:
|
||||
Dictionary with query results
|
||||
"""
|
||||
# Detect language
|
||||
language = "ar" if bool(re.search(r'[\u0600-\u06FF]', prompt)) else "en"
|
||||
filtered_apps = ['inventory', 'django_ledger', 'appointments', 'plans']
|
||||
try:
|
||||
analyzer = DjangoModelAnalyzer()
|
||||
model_structure = get_all_model_structures(filtered_apps=filtered_apps)
|
||||
|
||||
analysis = analyzer.analyze_prompt(prompt, model_structure)
|
||||
|
||||
if not analysis or not analysis.app_label or not analysis.model_name:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "تعذر العثور على النموذج المطلوب" if language == "ar" else "Missing model information",
|
||||
"language": language
|
||||
}
|
||||
|
||||
query_params = {
|
||||
"app_label": analysis.app_label,
|
||||
"model_name": analysis.model_name,
|
||||
"fields": [field.name for field in analysis.relevant_fields],
|
||||
"joins": [{"path": rel["field"], "type": rel["type"]} for rel in analysis.relationships],
|
||||
"filters": {},
|
||||
"aggregation": determine_aggregation_type(prompt, analysis.relevant_fields),
|
||||
"chart": determine_chart_type(prompt, analysis.relevant_fields),
|
||||
"language": language
|
||||
}
|
||||
|
||||
return query_django_model(query_params)
|
||||
except Exception as e:
|
||||
logger.error(f"Error analyzing prompt: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "حدث خطأ أثناء تحليل الاستعلام" if language == "ar" else f"Error analyzing prompt: {str(e)}",
|
||||
"language": language
|
||||
}
|
||||
@ -1,231 +0,0 @@
|
||||
from django.db.models import Avg, Sum, Max, Min, ForeignKey, OneToOneField
|
||||
import inspect
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def _localized_keys(language):
|
||||
if language == 'ar':
|
||||
return {
|
||||
'type': 'نوع', 'model': 'النموذج', 'count': 'العدد', 'filters': 'الفلاتر_المطبقة',
|
||||
'error': 'خطأ', 'chart_type': 'نوع_الرسم_البياني', 'labels': 'التسميات', 'data': 'البيانات',
|
||||
'visualization_data': 'بيانات_الرسم_البياني', 'field': 'الحقل', 'value': 'القيمة',
|
||||
'statistic_type': 'نوع_الإحصاء', 'results': 'النتائج', 'title': 'العنوان'
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'type': 'type', 'model': 'model', 'count': 'count', 'filters': 'filters_applied',
|
||||
'error': 'error', 'chart_type': 'chart_type', 'labels': 'labels', 'data': 'data',
|
||||
'visualization_data': 'visualization_data', 'field': 'field', 'value': 'value',
|
||||
'statistic_type': 'statistic_type', 'results': 'results', 'title': 'title'
|
||||
}
|
||||
|
||||
|
||||
def generate_count_insight(models, query_params, dealer_id=None, language='ar'):
|
||||
keys = _localized_keys(language)
|
||||
results = []
|
||||
|
||||
for model in models:
|
||||
try:
|
||||
queryset = model.objects.all()
|
||||
|
||||
if dealer_id:
|
||||
if hasattr(model, 'dealer_id'):
|
||||
queryset = queryset.filter(dealer_id=dealer_id)
|
||||
elif hasattr(model, 'dealer'):
|
||||
queryset = queryset.filter(dealer=dealer_id)
|
||||
|
||||
filters = {}
|
||||
for key, value in query_params.items():
|
||||
if key not in ['field', 'operation'] and hasattr(model, key):
|
||||
try:
|
||||
field = model._meta.get_field(key)
|
||||
if isinstance(field, models.IntegerField):
|
||||
value = int(value)
|
||||
elif isinstance(field, models.BooleanField):
|
||||
value = value.lower() in ('true', '1', 'yes')
|
||||
except Exception:
|
||||
pass
|
||||
filters[key] = value
|
||||
|
||||
if filters:
|
||||
queryset = queryset.filter(**filters)
|
||||
|
||||
results.append({
|
||||
keys['model']: model.__name__,
|
||||
keys['count']: queryset.count(),
|
||||
keys['filters']: filters
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
results.append({
|
||||
keys['model']: model.__name__,
|
||||
keys['error']: str(e)
|
||||
})
|
||||
|
||||
return {
|
||||
'type': keys['type'] + '_analysis',
|
||||
keys['results']: results,
|
||||
keys['visualization_data']: {
|
||||
keys['chart_type']: 'bar',
|
||||
keys['labels']: [r[keys['model']] for r in results if keys['count'] in r],
|
||||
keys['data']: [r[keys['count']] for r in results if keys['count'] in r]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def generate_statistics_insight(models, query_params, dealer_id=None, language='ar'):
|
||||
keys = _localized_keys(language)
|
||||
results = []
|
||||
field = query_params.get('field')
|
||||
operation = query_params.get('operation', 'average')
|
||||
|
||||
for model in models:
|
||||
try:
|
||||
if not field or not hasattr(model, field):
|
||||
continue
|
||||
|
||||
queryset = model.objects.all()
|
||||
if dealer_id:
|
||||
if hasattr(model, 'dealer_id'):
|
||||
queryset = queryset.filter(dealer_id=dealer_id)
|
||||
elif hasattr(model, 'dealer'):
|
||||
queryset = queryset.filter(dealer=dealer_id)
|
||||
|
||||
filters = {}
|
||||
for k, v in query_params.items():
|
||||
if k not in ['field', 'operation'] and hasattr(model, k):
|
||||
filters[k] = v
|
||||
|
||||
if filters:
|
||||
queryset = queryset.filter(**filters)
|
||||
|
||||
stat_map = {
|
||||
'average': Avg,
|
||||
'sum': Sum,
|
||||
'max': Max,
|
||||
'min': Min
|
||||
}
|
||||
|
||||
if operation in stat_map:
|
||||
agg = queryset.aggregate(val=stat_map[operation](field))['val']
|
||||
value = agg
|
||||
else:
|
||||
value = queryset.count()
|
||||
|
||||
results.append({
|
||||
keys['model']: model.__name__,
|
||||
keys['field']: field,
|
||||
keys['statistic_type']: operation,
|
||||
keys['value']: value,
|
||||
keys['filters']: filters
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
results.append({keys['model']: model.__name__, keys['error']: str(e)})
|
||||
|
||||
return {
|
||||
'type': keys['type'] + '_analysis',
|
||||
keys['results']: results,
|
||||
keys['visualization_data']: {
|
||||
keys['chart_type']: 'bar',
|
||||
keys['labels']: [f"{r[keys['model']]}.{r[keys['field']]}" for r in results if keys['value'] in r],
|
||||
keys['data']: [r[keys['value']] for r in results if keys['value'] in r],
|
||||
keys['title']: f"{operation} of {field}" if language != 'ar' else f"{field} ({operation})"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def generate_recommendations(model_classes, analysis_type, language='ar'):
|
||||
recs = []
|
||||
for model in model_classes:
|
||||
for field in model._meta.fields:
|
||||
if isinstance(field, ForeignKey) and not field.db_index:
|
||||
msg = f"أضف db_index=True إلى {model.__name__}.{field.name}" if language == 'ar' else f"Add db_index=True to {model.__name__}.{field.name}"
|
||||
recs.append(msg)
|
||||
if isinstance(field, models.CharField) and not field.db_index and field.name in ['name', 'title', 'description', 'text']:
|
||||
msg = f"فكر في فهرسة الحقل النصي {model.__name__}.{field.name}" if language == 'ar' else f"Consider indexing the text field {model.__name__}.{field.name}"
|
||||
recs.append(msg)
|
||||
return recs[:5]
|
||||
|
||||
|
||||
def generate_model_insight(model, dealer_id=None, language='ar'):
|
||||
keys = _localized_keys(language)
|
||||
fields_info = [
|
||||
{
|
||||
'name': f.name,
|
||||
'type': f.__class__.__name__,
|
||||
'null': f.null,
|
||||
'blank': f.blank,
|
||||
'unique': f.unique,
|
||||
'pk': f.primary_key
|
||||
} for f in model._meta.fields
|
||||
]
|
||||
|
||||
try:
|
||||
qs = model.objects.all()
|
||||
if dealer_id:
|
||||
if hasattr(model, 'dealer_id'):
|
||||
qs = qs.filter(dealer_id=dealer_id)
|
||||
elif hasattr(model, 'dealer'):
|
||||
qs = qs.filter(dealer=dealer_id)
|
||||
count = qs.count()
|
||||
except Exception:
|
||||
count = "error"
|
||||
|
||||
return {
|
||||
'type': keys['type'] + '_analysis',
|
||||
keys['model']: model.__name__,
|
||||
'fields': fields_info,
|
||||
'count': count
|
||||
}
|
||||
|
||||
|
||||
def generate_relationship_insight(models, query_params=None, dealer_id=None, language='ar'):
|
||||
from_ = "من" if language == 'ar' else "from"
|
||||
to_ = "إلى" if language == 'ar' else "to"
|
||||
rel_type = "نوع" if language == 'ar' else "type"
|
||||
relationships = []
|
||||
|
||||
for model in models:
|
||||
for field in model._meta.fields:
|
||||
if isinstance(field, (ForeignKey, OneToOneField)):
|
||||
relationships.append({
|
||||
from_: model.__name__,
|
||||
to_: field.related_model.__name__,
|
||||
rel_type: field.__class__.__name__
|
||||
})
|
||||
for field in model._meta.many_to_many:
|
||||
relationships.append({
|
||||
from_: model.__name__,
|
||||
to_: field.related_model.__name__,
|
||||
rel_type: 'ManyToManyField'
|
||||
})
|
||||
|
||||
return {
|
||||
'type': 'تحليل_العلاقات' if language == 'ar' else 'relationship_analysis',
|
||||
'relationships': relationships
|
||||
}
|
||||
|
||||
|
||||
def generate_performance_insight(models, query_params=None, dealer_id=None, language='ar'):
|
||||
issues = []
|
||||
for model in models:
|
||||
for field in model._meta.fields:
|
||||
if isinstance(field, ForeignKey) and not field.db_index:
|
||||
issues.append({
|
||||
'model': model.__name__,
|
||||
'field': field.name,
|
||||
'issue': 'Missing index on ForeignKey'
|
||||
})
|
||||
if isinstance(field, models.CharField) and not field.db_index and field.name in ['name', 'title']:
|
||||
issues.append({
|
||||
'model': model.__name__,
|
||||
'field': field.name,
|
||||
'issue': 'Unindexed CharField used in filtering'
|
||||
})
|
||||
|
||||
return {
|
||||
'type': 'تحليل_الأداء' if language == 'ar' else 'performance_analysis',
|
||||
'issues': issues
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
from openai import OpenAI
|
||||
from inventory import models
|
||||
from car_inventory import settings
|
||||
|
||||
def fetch_data(dealer):
|
||||
|
||||
try:
|
||||
# Annotate total cars by make, model, and trim
|
||||
cars = models.Car.objects.filter(dealer=dealer).count()
|
||||
print(cars)
|
||||
if cars:
|
||||
return f"إجمالي عدد السيارات في المخزون الخاص بـ {dealer.get_local_name}) هو {cars}"
|
||||
# return f"The total cars in {dealer} inventory is ( {cars} )."
|
||||
else:
|
||||
return "No cars found in the inventory."
|
||||
except Exception as e:
|
||||
return f"An error occurred while fetching car data: {str(e)}"
|
||||
|
||||
|
||||
def get_gpt4_response(user_input, dealer):
|
||||
"""
|
||||
Generates a response from the GPT-4 model based on the provided user input
|
||||
and the dealer's information. The function is tailored to assist with car
|
||||
inventory management, including queries about inventory, sales processes,
|
||||
car transfers, invoices, and other features specific to the Haikal system.
|
||||
|
||||
:param user_input: The text input or query provided by the user.
|
||||
:type user_input: str
|
||||
:param dealer: Dealer information or identifier used to fetch related car data
|
||||
or contextual information.
|
||||
:type dealer: Any
|
||||
:return: The generated response from the GPT-4 model as a string.
|
||||
:rtype: str
|
||||
:raises Exception: In case of an error during the API call to generate the
|
||||
GPT-4 response.
|
||||
"""
|
||||
dealer = dealer
|
||||
client = OpenAI(api_key=settings.OPENAI_API_KEY)
|
||||
|
||||
# if "سيارة في المخزون" in user_input.lower():
|
||||
# # cars = user_input.split("how many cars")[-1].strip()
|
||||
# car_data = fetch_data(dealer)
|
||||
# user_input += f" Relevant car data: {car_data}"
|
||||
try:
|
||||
completion = client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are an assistant specialized in car inventory management for the Haikal system. "
|
||||
"You can answer questions about the inventory, sales process, car transfers, invoices, "
|
||||
"and other application-specific functionalities. Always provide responses aligned "
|
||||
"with the Haikal system's structure and features."
|
||||
)
|
||||
},
|
||||
{"role": "user", "content": user_input},
|
||||
],
|
||||
)
|
||||
return completion.choices[0].message.content.strip()
|
||||
except Exception as e:
|
||||
return f"An error occurred while generating the response: {str(e)}"
|
||||
38
haikalbot/haikal_kb.yaml
Normal file
38
haikalbot/haikal_kb.yaml
Normal file
@ -0,0 +1,38 @@
|
||||
metadata:
|
||||
system_name: Haikal
|
||||
version: 1.0
|
||||
language: bilingual
|
||||
roles:
|
||||
- admin
|
||||
- dealer
|
||||
- branch
|
||||
- supplier
|
||||
|
||||
features:
|
||||
add_car:
|
||||
description: Add a new car to inventory
|
||||
steps:
|
||||
- Navigate to the "Inventory" section
|
||||
- Click "Add New Car"
|
||||
- Enter required fields: VIN, Make, Model, Year
|
||||
- Optional: Upload custom card, add warranty or insurance
|
||||
- Click "Save"
|
||||
permissions: ["admin", "dealer"]
|
||||
related_terms: ["chassis", "هيكل", "السيارة"]
|
||||
|
||||
create_invoice:
|
||||
description: Create a sale or purchase invoice
|
||||
steps:
|
||||
- Go to the "Invoices" page
|
||||
- Click "New Invoice"
|
||||
- Choose Type: Sale or Purchase
|
||||
- Select invoice_from and invoice_to
|
||||
- Link existing order(s)
|
||||
- Confirm and save
|
||||
permissions: ["admin", "dealer"]
|
||||
notes: Use sale for customer transactions, purchase for supplier buys
|
||||
|
||||
glossary:
|
||||
VIN: Vehicle Identification Number or chassis number (هيكل السيارة)
|
||||
custom_card: Official car registration document (استمارة)
|
||||
adjustment: Any cost added to or subtracted from an order
|
||||
@ -1,312 +0,0 @@
|
||||
# Integrating Ollama with LangChain for Django AI Analyst
|
||||
|
||||
This guide provides step-by-step instructions for integrating Ollama with LangChain in your Django AI Analyst application, with specific focus on Arabic language support.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Ollama installed on your system
|
||||
2. An Ollama model with Arabic support (preferably Jais-13B as recommended)
|
||||
3. Django project with the AI Analyst application
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. Install Required Python Packages
|
||||
|
||||
```bash
|
||||
pip install langchain langchain-community
|
||||
```
|
||||
|
||||
### 2. Configure Django Settings
|
||||
|
||||
Add the following to your Django settings.py file:
|
||||
|
||||
```python
|
||||
# Ollama and LangChain settings
|
||||
OLLAMA_BASE_URL = "http://10.10.1.132:11434" # Default Ollama API URL
|
||||
OLLAMA_MODEL = "qwen3:6b" # Or your preferred model
|
||||
OLLAMA_TIMEOUT = 120 # Seconds
|
||||
```
|
||||
|
||||
### 3. Create a LangChain Utility Module
|
||||
|
||||
Create a new file `ai_analyst/langchain_utils.py`:
|
||||
|
||||
```python
|
||||
from langchain.llms import Ollama
|
||||
from langchain.chains import LLMChain
|
||||
from langchain.prompts import PromptTemplate
|
||||
from django.conf import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_ollama_llm():
|
||||
"""
|
||||
Initialize and return an Ollama LLM instance configured for Arabic support.
|
||||
"""
|
||||
try:
|
||||
# Get settings from Django settings or use defaults
|
||||
base_url = getattr(settings, 'OLLAMA_BASE_URL', 'http://10.10.1.132:11434')
|
||||
model = getattr(settings, 'OLLAMA_MODEL', 'qwen3:8b')
|
||||
timeout = getattr(settings, 'OLLAMA_TIMEOUT', 120)
|
||||
|
||||
# Configure Ollama with appropriate parameters for Arabic
|
||||
return Ollama(
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
timeout=timeout,
|
||||
# Parameters to improve Arabic language generation
|
||||
parameters={
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"top_k": 40,
|
||||
"num_ctx": 2048, # Context window size
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing Ollama LLM: {str(e)}")
|
||||
return None
|
||||
|
||||
def create_prompt_analyzer_chain(language='ar'):
|
||||
"""
|
||||
Create a LangChain for analyzing prompts in Arabic or English.
|
||||
"""
|
||||
llm = get_ollama_llm()
|
||||
if not llm:
|
||||
return None
|
||||
|
||||
# Define the prompt template based on language
|
||||
if language == 'ar':
|
||||
template = """
|
||||
قم بتحليل الاستعلام التالي وتحديد نوع التحليل المطلوب ونماذج البيانات المستهدفة وأي معلمات استعلام.
|
||||
|
||||
الاستعلام: {prompt}
|
||||
|
||||
قم بتقديم إجابتك بتنسيق JSON كما يلي:
|
||||
{{
|
||||
"analysis_type": "count" أو "relationship" أو "performance" أو "statistics" أو "general",
|
||||
"target_models": ["ModelName1", "ModelName2"],
|
||||
"query_params": {{"field1": "value1", "field2": "value2"}}
|
||||
}}
|
||||
"""
|
||||
else:
|
||||
template = """
|
||||
Analyze the following prompt and determine the type of analysis required, target data models, and any query parameters.
|
||||
|
||||
Prompt: {prompt}
|
||||
|
||||
Provide your answer in JSON format as follows:
|
||||
{
|
||||
"analysis_type": "count" or "relationship" or "performance" or "statistics" or "general",
|
||||
"target_models": ["ModelName1", "ModelName2"],
|
||||
"query_params": {"field1": "value1", "field2": "value2"}
|
||||
}
|
||||
"""
|
||||
|
||||
# Create the prompt template
|
||||
prompt_template = PromptTemplate(
|
||||
input_variables=["prompt"],
|
||||
template=template
|
||||
)
|
||||
|
||||
# Create and return the LLM chain
|
||||
return LLMChain(llm=llm, prompt=prompt_template)
|
||||
```
|
||||
|
||||
### 4. Update Your View to Use LangChain
|
||||
|
||||
Modify your `ModelAnalystView` class to use the LangChain utilities:
|
||||
|
||||
```python
|
||||
from .langchain_utils import create_prompt_analyzer_chain
|
||||
import json
|
||||
import re
|
||||
|
||||
class ModelAnalystView(View):
|
||||
# ... existing code ...
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# We'll initialize chains on demand to avoid startup issues
|
||||
self.prompt_analyzer_chains = {}
|
||||
|
||||
def _get_prompt_analyzer_chain(self, language='ar'):
|
||||
"""
|
||||
Get or create a prompt analyzer chain for the specified language.
|
||||
"""
|
||||
if language not in self.prompt_analyzer_chains:
|
||||
self.prompt_analyzer_chains[language] = create_prompt_analyzer_chain(language)
|
||||
return self.prompt_analyzer_chains[language]
|
||||
|
||||
def _analyze_prompt_with_llm(self, prompt, language='ar'):
|
||||
"""
|
||||
Use LangChain and Ollama to analyze the prompt.
|
||||
"""
|
||||
try:
|
||||
# Get the appropriate chain for the language
|
||||
chain = self._get_prompt_analyzer_chain(language)
|
||||
if not chain:
|
||||
# Fallback to rule-based analysis if chain creation failed
|
||||
return self._analyze_prompt_rule_based(prompt, language)
|
||||
|
||||
# Run the chain
|
||||
result = chain.run(prompt=prompt)
|
||||
|
||||
# Parse the JSON result
|
||||
# Find JSON content within the response (in case the LLM adds extra text)
|
||||
json_match = re.search(r'({.*})', result.replace('\n', ' '), re.DOTALL)
|
||||
if json_match:
|
||||
json_str = json_match.group(1)
|
||||
return json.loads(json_str)
|
||||
else:
|
||||
# Fallback to rule-based analysis
|
||||
return self._analyze_prompt_rule_based(prompt, language)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in LLM prompt analysis: {str(e)}")
|
||||
# Fallback to rule-based analysis
|
||||
return self._analyze_prompt_rule_based(prompt, language)
|
||||
|
||||
def _analyze_prompt_rule_based(self, prompt, language='ar'):
|
||||
"""
|
||||
Rule-based fallback for prompt analysis.
|
||||
"""
|
||||
analysis_type, target_models, query_params = self._analyze_prompt(prompt, language)
|
||||
return {
|
||||
"analysis_type": analysis_type,
|
||||
"target_models": target_models,
|
||||
"query_params": query_params
|
||||
}
|
||||
|
||||
def _process_prompt(self, prompt, user, dealer_id, language='ar'):
|
||||
"""
|
||||
Process the natural language prompt and generate insights.
|
||||
"""
|
||||
# ... existing code ...
|
||||
|
||||
# Use LLM for prompt analysis
|
||||
analysis_result = self._analyze_prompt_with_llm(prompt, language)
|
||||
analysis_type = analysis_result.get('analysis_type', 'general')
|
||||
target_models = analysis_result.get('target_models', [])
|
||||
query_params = analysis_result.get('query_params', {})
|
||||
|
||||
# ... rest of the method ...
|
||||
```
|
||||
|
||||
## Testing the Integration
|
||||
|
||||
Create a test script to verify the Ollama and LangChain integration:
|
||||
|
||||
```python
|
||||
# test_ollama.py
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Set up Django environment
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'your_project.settings')
|
||||
django.setup()
|
||||
|
||||
from ai_analyst.langchain_utils import get_ollama_llm, create_prompt_analyzer_chain
|
||||
|
||||
def test_ollama_connection():
|
||||
"""Test basic Ollama connection and response."""
|
||||
llm = get_ollama_llm()
|
||||
if not llm:
|
||||
print("Failed to initialize Ollama LLM")
|
||||
return
|
||||
|
||||
# Test with Arabic prompt
|
||||
arabic_prompt = "مرحبا، كيف حالك؟"
|
||||
print(f"Testing Arabic prompt: {arabic_prompt}")
|
||||
try:
|
||||
response = llm.invoke(arabic_prompt)
|
||||
print(f"Response: {response}")
|
||||
print("Ollama connection successful!")
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
|
||||
def test_prompt_analysis():
|
||||
"""Test the prompt analyzer chain."""
|
||||
chain = create_prompt_analyzer_chain('ar')
|
||||
if not chain:
|
||||
print("Failed to create prompt analyzer chain")
|
||||
return
|
||||
|
||||
# Test with an Arabic analysis prompt
|
||||
analysis_prompt = "كم عدد السيارات التي لدينا؟"
|
||||
print(f"Testing analysis prompt: {analysis_prompt}")
|
||||
try:
|
||||
result = chain.run(prompt=analysis_prompt)
|
||||
print(f"Analysis result: {result}")
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Testing Ollama and LangChain integration...")
|
||||
test_ollama_connection()
|
||||
print("\n---\n")
|
||||
test_prompt_analysis()
|
||||
```
|
||||
|
||||
Run the test script:
|
||||
|
||||
```bash
|
||||
python test_ollama.py
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues and Solutions
|
||||
|
||||
1. **Ollama Connection Error**
|
||||
- Ensure Ollama is running: `ollama serve`
|
||||
- Check if the model is downloaded: `ollama list`
|
||||
- Verify the base URL in settings
|
||||
|
||||
2. **Model Not Found**
|
||||
- Download the model: `ollama pull jais:13b`
|
||||
- Check model name spelling in settings
|
||||
|
||||
3. **Timeout Errors**
|
||||
- Increase the timeout setting for complex queries
|
||||
- Consider using a smaller model if your hardware is limited
|
||||
|
||||
4. **Poor Arabic Analysis**
|
||||
- Ensure you're using an Arabic-capable model like Jais-13B
|
||||
- Check that your prompts are properly formatted in Arabic
|
||||
- Adjust temperature and other parameters for better results
|
||||
|
||||
5. **JSON Parsing Errors**
|
||||
- Improve the prompt template to emphasize strict JSON formatting
|
||||
- Implement more robust JSON extraction from LLM responses
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
For production use, consider these optimizations:
|
||||
|
||||
1. **Caching LLM Responses**
|
||||
- Implement Redis or another caching system for LLM responses
|
||||
- Cache common analysis patterns to reduce API calls
|
||||
|
||||
2. **Batch Processing**
|
||||
- For bulk analysis, use batch processing to reduce overhead
|
||||
|
||||
3. **Model Quantization**
|
||||
- If performance is slow, consider using a quantized version of the model
|
||||
- Example: `ollama pull jais:13b-q4_0` for a 4-bit quantized version
|
||||
|
||||
4. **Asynchronous Processing**
|
||||
- For long-running analyses, implement asynchronous processing with Celery
|
||||
|
||||
## Advanced Usage: Fine-tuning for Domain-Specific Analysis
|
||||
|
||||
For improved performance on your specific domain:
|
||||
|
||||
1. Create a dataset of example prompts and expected analyses
|
||||
2. Use Ollama's fine-tuning capabilities to adapt the model
|
||||
3. Update your application to use the fine-tuned model
|
||||
|
||||
## Conclusion
|
||||
|
||||
This integration enables your Django AI Analyst to leverage Ollama's powerful language models through LangChain, with specific optimizations for Arabic language support. The fallback to rule-based analysis ensures robustness, while the LLM-based approach provides more natural language understanding capabilities.
|
||||
BIN
haikalbot/management/.DS_Store
vendored
Normal file
BIN
haikalbot/management/.DS_Store
vendored
Normal file
Binary file not shown.
0
haikalbot/management/__init__.py
Normal file
0
haikalbot/management/__init__.py
Normal file
0
haikalbot/management/commands/__init__.py
Normal file
0
haikalbot/management/commands/__init__.py
Normal file
67
haikalbot/management/commands/generate_support_yaml.py
Normal file
67
haikalbot/management/commands/generate_support_yaml.py
Normal file
@ -0,0 +1,67 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.apps import apps
|
||||
import inspect
|
||||
import importlib
|
||||
import yaml
|
||||
import os
|
||||
from django.conf import settings
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate YAML support knowledge base from Django views and models"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
output_file = "haikal_kb.yaml"
|
||||
kb = {
|
||||
"metadata": {
|
||||
"system_name": "Haikal",
|
||||
"version": "1.0",
|
||||
"generated_from": "Django",
|
||||
},
|
||||
"features": {},
|
||||
"glossary": {}
|
||||
}
|
||||
|
||||
def extract_doc(item):
|
||||
doc = inspect.getdoc(item)
|
||||
return doc.strip() if doc else ""
|
||||
|
||||
def get_all_views_modules():
|
||||
view_modules = []
|
||||
for app in settings.INSTALLED_APPS:
|
||||
try:
|
||||
mod = importlib.import_module(f"{app}.views")
|
||||
view_modules.append((app, mod))
|
||||
except ImportError:
|
||||
continue
|
||||
return view_modules
|
||||
|
||||
def get_all_model_classes():
|
||||
all_models = []
|
||||
for model in apps.get_models():
|
||||
all_models.append((model._meta.app_label, model.__name__, extract_doc(model)))
|
||||
return all_models
|
||||
|
||||
# Extract views
|
||||
for app, mod in get_all_views_modules():
|
||||
for name, obj in inspect.getmembers(mod, inspect.isfunction):
|
||||
doc = extract_doc(obj)
|
||||
if doc:
|
||||
kb["features"][name] = {
|
||||
"description": doc,
|
||||
"source": f"{app}.views.{name}",
|
||||
"type": "view_function"
|
||||
}
|
||||
|
||||
# Extract models
|
||||
for app, name, doc in get_all_model_classes():
|
||||
if doc:
|
||||
kb["features"][name] = {
|
||||
"description": doc,
|
||||
"source": f"{app}.models.{name}",
|
||||
"type": "model_class"
|
||||
}
|
||||
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
yaml.dump(kb, f, allow_unicode=True, sort_keys=False)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"✅ YAML knowledge base saved to {output_file}"))
|
||||
@ -1,5 +1,8 @@
|
||||
# Generated by Django 5.2.1 on 2025-05-25 23:01
|
||||
# Generated by Django 5.1.7 on 2025-06-01 11:25
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@ -8,6 +11,7 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -17,7 +21,26 @@ class Migration(migrations.Migration):
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('user_message', models.TextField()),
|
||||
('chatbot_response', models.TextField()),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-timestamp'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AnalysisCache',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('prompt_hash', models.CharField(db_index=True, max_length=64)),
|
||||
('dealer_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('expires_at', models.DateTimeField(db_index=True)),
|
||||
('result', models.JSONField()),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Analysis caches',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.1 on 2025-05-25 23:01
|
||||
# Generated by Django 5.1.7 on 2025-06-01 11:25
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
@ -19,4 +19,16 @@ class Migration(migrations.Migration):
|
||||
name='dealer',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chatlogs', to='inventory.dealer'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='analysiscache',
|
||||
index=models.Index(fields=['prompt_hash', 'dealer_id'], name='haikalbot_a_prompt__b98e1e_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='analysiscache',
|
||||
index=models.Index(fields=['expires_at'], name='haikalbot_a_expires_e790cd_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='chatlog',
|
||||
index=models.Index(fields=['dealer', 'timestamp'], name='haikalbot_c_dealer__6f8d63_idx'),
|
||||
),
|
||||
]
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
# Generated by Django 5.2.1 on 2025-05-26 00:28
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('haikalbot', '0002_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AnalysisCache',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('prompt_hash', models.CharField(db_index=True, max_length=64)),
|
||||
('dealer_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('result', models.JSONField()),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['prompt_hash', 'dealer_id'], name='haikalbot_a_prompt__b98e1e_idx'), models.Index(fields=['expires_at'], name='haikalbot_a_expires_e790cd_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,36 +0,0 @@
|
||||
# Generated by Django 5.2.1 on 2025-05-26 08:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('haikalbot', '0003_analysiscache'),
|
||||
('inventory', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='analysiscache',
|
||||
options={'verbose_name_plural': 'Analysis caches'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='chatlog',
|
||||
options={'ordering': ['-timestamp']},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='analysiscache',
|
||||
name='expires_at',
|
||||
field=models.DateTimeField(db_index=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='chatlog',
|
||||
name='timestamp',
|
||||
field=models.DateTimeField(auto_now_add=True, db_index=True),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='chatlog',
|
||||
index=models.Index(fields=['dealer', 'timestamp'], name='haikalbot_c_dealer__6f8d63_idx'),
|
||||
),
|
||||
]
|
||||
BIN
haikalbot/model_analyzer_refactored.zip
Normal file
BIN
haikalbot/model_analyzer_refactored.zip
Normal file
Binary file not shown.
@ -1,76 +0,0 @@
|
||||
# Recommended Ollama Models for Arabic Language Support
|
||||
|
||||
## Top Recommendations
|
||||
|
||||
1. **Jais-13B** (Recommended)
|
||||
- **Size**: 13 billion parameters
|
||||
- **Strengths**: Specifically trained on Arabic content, excellent understanding of Arabic context and nuances
|
||||
- **Command**: `ollama pull jais:13b`
|
||||
- **Best for**: Production-quality Arabic language understanding and generation
|
||||
|
||||
2. **BLOOM-7B**
|
||||
- **Size**: 7 billion parameters
|
||||
- **Strengths**: Trained on 46 languages including Arabic, good multilingual capabilities
|
||||
- **Command**: `ollama pull bloom:7b`
|
||||
- **Best for**: Multilingual applications where Arabic is one of several languages
|
||||
|
||||
3. **Mistral-7B-Instruct**
|
||||
- **Size**: 7 billion parameters
|
||||
- **Strengths**: Strong general performance, good instruction following, reasonable Arabic support
|
||||
- **Command**: `ollama pull mistral:7b-instruct`
|
||||
- **Best for**: General purpose applications with moderate Arabic requirements
|
||||
|
||||
4. **Qwen2-7B**
|
||||
- **Size**: 7 billion parameters
|
||||
- **Strengths**: Good multilingual capabilities including Arabic
|
||||
- **Command**: `ollama pull qwen2:7b`
|
||||
- **Best for**: Applications requiring both Chinese and Arabic support
|
||||
|
||||
## Comparison Table
|
||||
|
||||
| Model | Size | Arabic Support | Instruction Following | Resource Requirements | Command |
|
||||
|-------|------|---------------|----------------------|----------------------|---------|
|
||||
| Jais-13B | 13B | Excellent | Very Good | High (16GB+ RAM) | `ollama pull jais:13b` |
|
||||
| BLOOM-7B | 7B | Good | Good | Medium (8GB+ RAM) | `ollama pull bloom:7b` |
|
||||
| Mistral-7B-Instruct | 7B | Moderate | Excellent | Medium (8GB+ RAM) | `ollama pull mistral:7b-instruct` |
|
||||
| Qwen2-7B | 7B | Good | Very Good | Medium (8GB+ RAM) | `ollama pull qwen2:7b` |
|
||||
|
||||
## Justification for Jais-13B Recommendation
|
||||
|
||||
Jais-13B is specifically recommended for your Django AI Analyst application because:
|
||||
|
||||
1. **Arabic-First Design**: Unlike most models that treat Arabic as one of many languages, Jais was specifically designed for Arabic language understanding and generation.
|
||||
|
||||
2. **Cultural Context**: The model has better understanding of Arabic cultural contexts and nuances, which is important for analyzing domain-specific queries about your data models.
|
||||
|
||||
3. **Technical Terminology**: Better handling of technical terms in Arabic, which is crucial for a model analyzing Django models and database structures.
|
||||
|
||||
4. **Instruction Following**: Good ability to follow complex instructions in Arabic, which is essential for your prompt-based analysis system.
|
||||
|
||||
5. **Performance on Analytical Tasks**: Superior performance on analytical and reasoning tasks in Arabic compared to general multilingual models.
|
||||
|
||||
If your system has limited resources (less than 12GB RAM), Mistral-7B-Instruct would be the next best alternative, offering a good balance between performance and resource requirements.
|
||||
|
||||
## Installation Instructions
|
||||
|
||||
To install the recommended Jais-13B model:
|
||||
|
||||
```bash
|
||||
ollama pull jais:13b
|
||||
```
|
||||
|
||||
For systems with limited resources, install Mistral-7B-Instruct instead:
|
||||
|
||||
```bash
|
||||
ollama pull mistral:7b-instruct
|
||||
```
|
||||
|
||||
After installation, update the `OLLAMA_MODEL` setting in your Django view:
|
||||
|
||||
```python
|
||||
# For Jais-13B
|
||||
OLLAMA_MODEL = 'jais:13b'
|
||||
|
||||
# OR for Mistral-7B-Instruct if resources are limited
|
||||
# OLLAMA_MODEL = 'mistral:7b-instruct'
|
||||
```
|
||||
19
haikalbot/run_haikal_qa.py
Normal file
19
haikalbot/run_haikal_qa.py
Normal file
@ -0,0 +1,19 @@
|
||||
from langchain.document_loaders import TextLoader
|
||||
from langchain.indexes import VectorstoreIndexCreator
|
||||
from langchain.chat_models import ChatOpenAI
|
||||
from langchain.chains import RetrievalQA
|
||||
|
||||
# Load YAML doc
|
||||
loader = TextLoader("haikal_kb.yaml")
|
||||
index = VectorstoreIndexCreator().from_loaders([loader])
|
||||
|
||||
# Setup QA chain
|
||||
qa = RetrievalQA.from_chain_type(
|
||||
llm=ChatOpenAI(model="gpt-3.5-turbo", temperature=0),
|
||||
retriever=index.vectorstore.as_retriever()
|
||||
)
|
||||
|
||||
# Ask a question
|
||||
query = "How do I add a new invoice?"
|
||||
response = qa.run(query)
|
||||
print("Answer:", response)
|
||||
@ -1,227 +0,0 @@
|
||||
import inspect
|
||||
import hashlib
|
||||
from django.db import models
|
||||
from django.db.models import Avg, Sum, Max, Min, ForeignKey, OneToOneField, Count
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def _localized_keys(language):
|
||||
return {
|
||||
'type': 'نوع' if language == 'ar' else 'type',
|
||||
'model': 'النموذج' if language == 'ar' else 'model',
|
||||
'count': 'العدد' if language == 'ar' else 'count',
|
||||
'filters': 'الفلاتر_المطبقة' if language == 'ar' else 'filters_applied',
|
||||
'error': 'خطأ' if language == 'ar' else 'error',
|
||||
'chart_type': 'نوع_الرسم_البياني' if language == 'ar' else 'chart_type',
|
||||
'labels': 'التسميات' if language == 'ar' else 'labels',
|
||||
'data': 'البيانات' if language == 'ar' else 'data',
|
||||
'visualization_data': 'بيانات_الرسم_البياني' if language == 'ar' else 'visualization_data',
|
||||
'field': 'الحقل' if language == 'ar' else 'field',
|
||||
'value': 'القيمة' if language == 'ar' else 'value',
|
||||
'statistic_type': 'نوع_الإحصاء' if language == 'ar' else 'statistic_type',
|
||||
'results': 'النتائج' if language == 'ar' else 'results',
|
||||
'title': 'العنوان' if language == 'ar' else 'title',
|
||||
}
|
||||
|
||||
|
||||
def generate_count_insight(models, query_params, dealer_id=None, language='en'):
|
||||
keys = _localized_keys(language)
|
||||
results = []
|
||||
|
||||
for model in models:
|
||||
try:
|
||||
queryset = model.objects.all()
|
||||
|
||||
if dealer_id:
|
||||
if hasattr(model, 'dealer_id'):
|
||||
queryset = queryset.filter(dealer_id=dealer_id)
|
||||
elif hasattr(model, 'dealer'):
|
||||
queryset = queryset.filter(dealer=dealer_id)
|
||||
|
||||
filters = {}
|
||||
for key, value in query_params.items():
|
||||
if key in ['field', 'operation']:
|
||||
continue
|
||||
if hasattr(model, key):
|
||||
try:
|
||||
field = model._meta.get_field(key)
|
||||
if isinstance(field, models.IntegerField):
|
||||
value = int(value)
|
||||
elif isinstance(field, models.BooleanField):
|
||||
value = value.lower() in ('true', '1', 'yes')
|
||||
except Exception:
|
||||
pass
|
||||
filters[key] = value
|
||||
|
||||
if filters:
|
||||
queryset = queryset.filter(**filters)
|
||||
|
||||
results.append({
|
||||
keys['model']: model.__name__,
|
||||
keys['count']: queryset.count(),
|
||||
keys['filters']: filters,
|
||||
})
|
||||
except Exception as e:
|
||||
results.append({
|
||||
keys['model']: model.__name__,
|
||||
keys['error']: str(e),
|
||||
})
|
||||
|
||||
return {
|
||||
keys['type']: keys['type'] + '_analysis',
|
||||
keys['results']: results,
|
||||
keys['visualization_data']: {
|
||||
keys['chart_type']: 'bar',
|
||||
keys['labels']: [r[keys['model']] for r in results if keys['count'] in r],
|
||||
keys['data']: [r[keys['count']] for r in results if keys['count'] in r],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def generate_statistics_insight(models, query_params, dealer_id=None, language='en'):
|
||||
keys = _localized_keys(language)
|
||||
results = []
|
||||
field = query_params.get('field')
|
||||
operation = query_params.get('operation', 'average')
|
||||
stat_map = {'average': Avg, 'sum': Sum, 'max': Max, 'min': Min}
|
||||
|
||||
for model in models:
|
||||
try:
|
||||
if not field or not hasattr(model, field):
|
||||
continue
|
||||
|
||||
queryset = model.objects.all()
|
||||
if dealer_id:
|
||||
if hasattr(model, 'dealer_id'):
|
||||
queryset = queryset.filter(dealer_id=dealer_id)
|
||||
elif hasattr(model, 'dealer'):
|
||||
queryset = queryset.filter(dealer=dealer_id)
|
||||
|
||||
filters = {
|
||||
k: v for k, v in query_params.items()
|
||||
if k not in ['field', 'operation'] and hasattr(model, k)
|
||||
}
|
||||
|
||||
if filters:
|
||||
queryset = queryset.filter(**filters)
|
||||
|
||||
value = queryset.aggregate(val=stat_map.get(operation, Count)(field))['val']
|
||||
|
||||
results.append({
|
||||
keys['model']: model.__name__,
|
||||
keys['field']: field,
|
||||
keys['statistic_type']: operation,
|
||||
keys['value']: value,
|
||||
keys['filters']: filters,
|
||||
})
|
||||
except Exception as e:
|
||||
results.append({
|
||||
keys['model']: model.__name__,
|
||||
keys['error']: str(e),
|
||||
})
|
||||
|
||||
return {
|
||||
keys['type']: keys['type'] + '_analysis',
|
||||
keys['results']: results,
|
||||
keys['visualization_data']: {
|
||||
keys['chart_type']: 'bar',
|
||||
keys['labels']: [f"{r[keys['model']]}.{r[keys['field']]}" for r in results if keys['value'] in r],
|
||||
keys['data']: [r[keys['value']] for r in results if keys['value'] in r],
|
||||
keys['title']: f"{operation} of {field}" if language != 'ar' else f"{field} ({operation})"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def generate_recommendations(model_classes, analysis_type, language='en'):
|
||||
recs = []
|
||||
for model in model_classes:
|
||||
for field in model._meta.fields:
|
||||
if isinstance(field, ForeignKey) and not field.db_index:
|
||||
msg = f"أضف db_index=True إلى {model.__name__}.{field.name}" if language == 'ar' else f"Add db_index=True to {model.__name__}.{field.name}"
|
||||
recs.append(msg)
|
||||
if isinstance(field, models.CharField) and not field.db_index and field.name in ['name', 'title', 'description', 'text']:
|
||||
msg = f"فكر في فهرسة الحقل النصي {model.__name__}.{field.name}" if language == 'ar' else f"Consider indexing the text field {model.__name__}.{field.name}"
|
||||
recs.append(msg)
|
||||
return recs[:5]
|
||||
|
||||
|
||||
def generate_model_insight(model, dealer_id=None, language='en'):
|
||||
keys = _localized_keys(language)
|
||||
fields_info = [{
|
||||
'name': f.name,
|
||||
'type': f.__class__.__name__,
|
||||
'null': f.null,
|
||||
'blank': f.blank,
|
||||
'unique': f.unique,
|
||||
'pk': f.primary_key
|
||||
} for f in model._meta.fields]
|
||||
|
||||
try:
|
||||
qs = model.objects.all()
|
||||
if dealer_id:
|
||||
if hasattr(model, 'dealer'):
|
||||
qs = qs.filter(dealer_id=dealer_id)
|
||||
elif hasattr(model, 'dealer'):
|
||||
qs = qs.filter(dealer=dealer_id)
|
||||
count = qs.count()
|
||||
except Exception:
|
||||
count = "error"
|
||||
|
||||
return {
|
||||
keys['type']: keys['type'] + '_analysis',
|
||||
keys['model']: model.__name__,
|
||||
'fields': fields_info,
|
||||
'count': count
|
||||
}
|
||||
|
||||
|
||||
def generate_relationship_insight(models, query_params=None, dealer_id=None, language='en'):
|
||||
from_ = "من" if language == 'ar' else "from"
|
||||
to_ = "إلى" if language == 'ar' else "to"
|
||||
rel_type = "نوع" if language == 'ar' else "type"
|
||||
relationships = []
|
||||
|
||||
for model in models:
|
||||
for field in model._meta.fields:
|
||||
if isinstance(field, (ForeignKey, OneToOneField)):
|
||||
relationships.append({
|
||||
from_: model.__name__,
|
||||
to_: field.related_model.__name__,
|
||||
rel_type: field.__class__.__name__,
|
||||
})
|
||||
for field in model._meta.many_to_many:
|
||||
relationships.append({
|
||||
from_: model.__name__,
|
||||
to_: field.related_model.__name__,
|
||||
rel_type: 'ManyToManyField'
|
||||
})
|
||||
|
||||
return {
|
||||
'type': 'تحليل_العلاقات' if language == 'ar' else 'relationship_analysis',
|
||||
'relationships': relationships
|
||||
}
|
||||
|
||||
|
||||
def generate_performance_insight(models, query_params=None, dealer_id=None, language='en'):
|
||||
issues = []
|
||||
|
||||
for model in models:
|
||||
for field in model._meta.fields:
|
||||
if isinstance(field, ForeignKey) and not field.db_index:
|
||||
issues.append({
|
||||
# 'model': model.__name__,
|
||||
'field': field.name,
|
||||
'issue': 'Missing index on ForeignKey'
|
||||
})
|
||||
# if isinstance(field, models.CharField) and not field.db_index and field.name in ['name', 'title']:
|
||||
# issues.append({
|
||||
# 'model': model.__name__,
|
||||
# 'field': field.name,
|
||||
# 'issue': 'Unindexed CharField used in filtering'
|
||||
# })
|
||||
|
||||
return {
|
||||
'type': 'تحليل_الأداء' if language == 'ar' else 'performance_analysis',
|
||||
'issues': issues
|
||||
}
|
||||
@ -1,61 +0,0 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from django.utils import timezone
|
||||
from django.db import models
|
||||
from ..models import AnalysisCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CacheService:
|
||||
def generate_hash(self, prompt, dealer_id, language):
|
||||
"""
|
||||
Generate a unique MD5 hash based on the prompt, dealer ID, and language.
|
||||
"""
|
||||
key = f"{prompt}:{dealer_id or 'all'}:{language}"
|
||||
return hashlib.md5(key.encode()).hexdigest()
|
||||
|
||||
def get_cached_result(self, prompt_hash, user, dealer_id):
|
||||
"""
|
||||
Retrieve a cached analysis result based on hash, dealer, and optionally user.
|
||||
"""
|
||||
try:
|
||||
# Check for user-specific cache if authenticated
|
||||
if user and user.is_authenticated:
|
||||
user_cache = AnalysisCache.objects.filter(
|
||||
prompt_hash=prompt_hash,
|
||||
user=user,
|
||||
expires_at__gt=timezone.now()
|
||||
).first()
|
||||
if user_cache:
|
||||
return user_cache.result
|
||||
|
||||
# Otherwise check for dealer-wide cache
|
||||
dealer_cache = AnalysisCache.objects.filter(
|
||||
prompt_hash=prompt_hash,
|
||||
dealer_id=dealer_id,
|
||||
expires_at__gt=timezone.now()
|
||||
).first()
|
||||
|
||||
return dealer_cache.result if dealer_cache else None
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache retrieval failed: {str(e)}")
|
||||
return None
|
||||
|
||||
def cache_result(self, prompt_hash, result, user, dealer_id, duration=3600):
|
||||
"""
|
||||
Save or update a cached result with an expiration timestamp.
|
||||
"""
|
||||
try:
|
||||
expires_at = timezone.now() + timezone.timedelta(seconds=duration)
|
||||
AnalysisCache.objects.update_or_create(
|
||||
prompt_hash=prompt_hash,
|
||||
user=user if user and user.is_authenticated else None,
|
||||
dealer_id=dealer_id,
|
||||
defaults={
|
||||
'result': result,
|
||||
'expires_at': expires_at
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache saving failed: {str(e)}")
|
||||
@ -1,150 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
from django.apps import apps
|
||||
from django.http import JsonResponse
|
||||
from django.db.models import Count, Avg, Max, Min
|
||||
from langchain_ollama import OllamaLLM
|
||||
from langchain.chains import LLMChain
|
||||
from langchain.prompts import PromptTemplate
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_llm_instance():
|
||||
try:
|
||||
base_url = getattr(settings, 'OLLAMA_BASE_URL', 'http://10.10.1.132:11434')
|
||||
model = getattr(settings, 'OLLAMA_MODEL', 'qwen3:8b')
|
||||
temperature = getattr(settings, 'OLLAMA_TEMPERATURE', 0.2)
|
||||
top_p = getattr(settings, 'OLLAMA_TOP_P', 0.8)
|
||||
top_k = getattr(settings, 'OLLAMA_TOP_K', 40)
|
||||
num_ctx = getattr(settings, 'OLLAMA_NUM_CTX', 4096)
|
||||
num_predict = getattr(settings, 'OLLAMA_NUM_PREDICT', 2048)
|
||||
|
||||
return OllamaLLM(
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
top_k=top_k,
|
||||
num_ctx=num_ctx,
|
||||
num_predict=num_predict,
|
||||
stop=["```", "</s>"],
|
||||
repeat_penalty=1.1,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing Ollama LLM: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def get_llm_chain(language='en'):
|
||||
llm = get_llm_instance()
|
||||
if not llm:
|
||||
return None
|
||||
|
||||
if language == 'ar':
|
||||
template = """
|
||||
قم بتحليل الاستعلام التالي وتحديد نوع التحليل المطلوب ونماذج البيانات المستهدفة وأي معلمات استعلام.
|
||||
|
||||
الاستعلام: {prompt}
|
||||
|
||||
قم بتقديم إجابتك بتنسيق JSON كما يلي:
|
||||
{{
|
||||
"analysis_type": "count" أو "relationship" أو "performance" أو "statistics" أو "general",
|
||||
"target_models": ["ModelName1", "ModelName2"],
|
||||
"query_params": {{"field1": "value1", "field2": "value2"}}
|
||||
}}
|
||||
"""
|
||||
else:
|
||||
template = """
|
||||
Analyze the following prompt and determine the type of analysis required, target data models, and any query parameters.
|
||||
|
||||
Prompt: {prompt}
|
||||
|
||||
Provide your answer in JSON format as follows:
|
||||
{
|
||||
"analysis_type": "count" or "relationship" or "performance" or "statistics" or "general",
|
||||
"target_models": ["ModelName1", "ModelName2"],
|
||||
"query_params": {"field1": "value1", "field2": "value2"}
|
||||
}
|
||||
"""
|
||||
|
||||
prompt_template = PromptTemplate(
|
||||
input_variables=["prompt"],
|
||||
template=template
|
||||
)
|
||||
|
||||
return prompt_template | llm
|
||||
|
||||
|
||||
def analyze_models_with_orm(analysis_type, target_models, query_params):
|
||||
results = {}
|
||||
|
||||
for model_name in target_models:
|
||||
try:
|
||||
model = apps.get_model('your_app_name', model_name)
|
||||
except LookupError:
|
||||
results[model_name] = {"error": f"Model '{model_name}' not found"}
|
||||
continue
|
||||
|
||||
try:
|
||||
queryset = model.objects.filter(**query_params)
|
||||
|
||||
if analysis_type == 'count':
|
||||
results[model_name] = {'count': queryset.count()}
|
||||
|
||||
elif analysis_type == 'statistics':
|
||||
numeric_fields = [f.name for f in model._meta.fields if f.get_internal_type() in ['IntegerField', 'FloatField', 'DecimalField']]
|
||||
stats = {}
|
||||
for field in numeric_fields:
|
||||
stats[field] = {
|
||||
'avg': queryset.aggregate(avg=Avg(field))['avg'],
|
||||
'max': queryset.aggregate(max=Max(field))['max'],
|
||||
'min': queryset.aggregate(min=Min(field))['min']
|
||||
}
|
||||
results[model_name] = stats
|
||||
|
||||
elif analysis_type == 'relationship':
|
||||
related = {}
|
||||
for field in model._meta.get_fields():
|
||||
if field.is_relation and field.many_to_one:
|
||||
related[field.name] = queryset.values(field.name).annotate(count=Count(field.name)).count()
|
||||
results[model_name] = related
|
||||
|
||||
elif analysis_type == 'performance':
|
||||
results[model_name] = {'note': 'Performance analysis logic not implemented.'}
|
||||
|
||||
else:
|
||||
results[model_name] = list(queryset.values())
|
||||
|
||||
except Exception as e:
|
||||
results[model_name] = {'error': str(e)}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def analyze_prompt_and_return_json(request):
|
||||
try:
|
||||
prompt = request.POST.get('prompt')
|
||||
language = request.POST.get('language', 'en')
|
||||
|
||||
chain = get_llm_chain(language)
|
||||
if not chain:
|
||||
return JsonResponse({'success': False, 'error': 'LLM not initialized'})
|
||||
|
||||
result = chain.invoke({'prompt': prompt})
|
||||
parsed = json.loads(result)
|
||||
|
||||
analysis_type = parsed.get('analysis_type')
|
||||
target_models = parsed.get('target_models', [])
|
||||
query_params = parsed.get('query_params', {})
|
||||
|
||||
if not analysis_type or not target_models:
|
||||
return JsonResponse({'success': False, 'error': 'Incomplete analysis instruction returned by LLM'})
|
||||
|
||||
orm_results = analyze_models_with_orm(analysis_type, target_models, query_params)
|
||||
|
||||
return JsonResponse({'success': True, 'data': orm_results})
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({'success': False, 'error': str(e)})
|
||||
@ -1,80 +0,0 @@
|
||||
from langchain_ollama import OllamaLLM
|
||||
|
||||
from langchain.chains import LLMChain
|
||||
from langchain.prompts import PromptTemplate
|
||||
from django.conf import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ollama_llm():
|
||||
"""
|
||||
Initialize and return an Ollama LLM instance configured for Arabic support.
|
||||
"""
|
||||
try:
|
||||
# Get settings from Django settings or use defaults
|
||||
base_url = getattr(settings, 'OLLAMA_BASE_URL', 'http://localhost:11434')
|
||||
model = getattr(settings, 'OLLAMA_MODEL', 'qwen3:8b')
|
||||
# timeout = getattr(settings, 'OLLAMA_TIMEOUT', 120)
|
||||
|
||||
return OllamaLLM(
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
temperature= 0.2,
|
||||
top_p= 0.8,
|
||||
top_k= 40,
|
||||
num_ctx= 4096,
|
||||
num_predict= 2048,
|
||||
stop= ["```", "</s>"],
|
||||
repeat_penalty= 1.1,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing Ollama LLM: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def create_prompt_analyzer_chain(language='ar'):
|
||||
"""
|
||||
Create a LangChain for analyzing prompts in Arabic or English.
|
||||
"""
|
||||
llm = get_ollama_llm()
|
||||
if not llm:
|
||||
return None
|
||||
|
||||
# Define the prompt template based on language
|
||||
if language == 'ar':
|
||||
template = """
|
||||
قم بتحليل الاستعلام التالي وتحديد نوع التحليل المطلوب ونماذج البيانات المستهدفة وأي معلمات استعلام.
|
||||
|
||||
الاستعلام: {prompt}
|
||||
|
||||
قم بتقديم إجابتك بتنسيق JSON كما يلي:
|
||||
{{
|
||||
"analysis_type": "count" أو "relationship" أو "performance" أو "statistics" أو "general",
|
||||
"target_models": ["ModelName1", "ModelName2"],
|
||||
"query_params": {{"field1": "value1", "field2": "value2"}}
|
||||
}}
|
||||
"""
|
||||
else:
|
||||
template = """
|
||||
Analyze the following prompt and determine the type of analysis required, target data models, and any query parameters.
|
||||
|
||||
Prompt: {prompt}
|
||||
|
||||
Provide your answer in JSON format as follows:
|
||||
{
|
||||
"analysis_type": "count" or "relationship" or "performance" or "statistics" or "general",
|
||||
"target_models": ["ModelName1", "ModelName2"],
|
||||
"query_params": {"field1": "value1", "field2": "value2"}
|
||||
}
|
||||
"""
|
||||
|
||||
# Create the prompt template
|
||||
prompt_template = PromptTemplate(
|
||||
input_variables=["prompt"],
|
||||
template=template
|
||||
)
|
||||
|
||||
# Create and return the LLM chain
|
||||
return prompt_template | llm
|
||||
@ -1,161 +0,0 @@
|
||||
# Training Prompt for Django Model Analyst AI Agent
|
||||
|
||||
## Agent Purpose
|
||||
You are a specialized AI agent designed to analyze Django models and provide insightful information to users. Your primary function is to interpret Django model structures, relationships, and metadata to generate meaningful insights that help developers and stakeholders understand their data models better.
|
||||
|
||||
## Core Capabilities
|
||||
1. Parse and understand Django model definitions
|
||||
2. Identify relationships between models (ForeignKey, ManyToMany, OneToOne)
|
||||
3. Analyze model fields, types, constraints, and metadata
|
||||
4. Generate statistics and insights about model usage and structure
|
||||
5. Provide recommendations for model optimization
|
||||
6. Respond to natural language queries about models
|
||||
7. Format responses as structured JSON for integration with frontend applications
|
||||
|
||||
## Input Processing
|
||||
You will receive inputs in the following format:
|
||||
1. Django model code or references to model files
|
||||
2. A natural language prompt specifying the type of analysis or insights requested
|
||||
3. Optional context about the project or specific concerns
|
||||
|
||||
## Output Requirements
|
||||
Your responses must:
|
||||
1. Be formatted as valid JSON
|
||||
2. Include a "status" field indicating success or failure
|
||||
3. Provide an "insights" array containing the requested analysis
|
||||
4. Include metadata about the analysis performed
|
||||
5. Be structured in a way that's easy to parse and display in a frontend
|
||||
|
||||
## Analysis Types
|
||||
You should be able to perform the following types of analysis:
|
||||
|
||||
### Structural Analysis
|
||||
- Model count and complexity metrics
|
||||
- Field type distribution
|
||||
- Relationship mapping and visualization data
|
||||
- Inheritance patterns
|
||||
- Abstract models usage
|
||||
|
||||
### Performance Analysis
|
||||
- Potential query bottlenecks
|
||||
- Missing index recommendations
|
||||
- Relationship optimization suggestions
|
||||
- N+1 query vulnerability detection
|
||||
|
||||
### Security Analysis
|
||||
- Sensitive field detection
|
||||
- Permission model recommendations
|
||||
- Data exposure risk assessment
|
||||
|
||||
### Data Integrity Analysis
|
||||
- Constraint analysis
|
||||
- Validation rule assessment
|
||||
- Data consistency recommendations
|
||||
|
||||
## Example Interactions
|
||||
|
||||
### Example 1: Basic Model Analysis
|
||||
**Input Prompt:**
|
||||
"Analyze the User and Profile models and show me their relationship structure."
|
||||
|
||||
**Expected Response:**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"request_id": "a1b2c3d4",
|
||||
"timestamp": "2025-05-25T23:21:56Z",
|
||||
"insights": [
|
||||
{
|
||||
"type": "relationship_analysis",
|
||||
"models": ["User", "Profile"],
|
||||
"relationships": [
|
||||
{
|
||||
"from": "Profile",
|
||||
"to": "User",
|
||||
"type": "OneToOne",
|
||||
"field": "user",
|
||||
"related_name": "profile",
|
||||
"on_delete": "CASCADE"
|
||||
}
|
||||
],
|
||||
"visualization_data": {
|
||||
"nodes": [...],
|
||||
"edges": [...]
|
||||
}
|
||||
}
|
||||
],
|
||||
"recommendations": [
|
||||
"Consider adding an index to Profile.user for faster lookups"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2: Query Performance Analysis
|
||||
**Input Prompt:**
|
||||
"Identify potential performance issues in the Order and OrderItem models."
|
||||
|
||||
**Expected Response:**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"request_id": "e5f6g7h8",
|
||||
"timestamp": "2025-05-25T23:22:30Z",
|
||||
"insights": [
|
||||
{
|
||||
"type": "performance_analysis",
|
||||
"models": ["Order", "OrderItem"],
|
||||
"issues": [
|
||||
{
|
||||
"severity": "high",
|
||||
"model": "OrderItem",
|
||||
"field": "order",
|
||||
"issue": "Missing database index on ForeignKey",
|
||||
"impact": "Slow queries when filtering OrderItems by Order",
|
||||
"solution": "Add db_index=True to order field"
|
||||
},
|
||||
{
|
||||
"severity": "medium",
|
||||
"model": "Order",
|
||||
"issue": "No select_related in common queries",
|
||||
"impact": "Potential N+1 query problems",
|
||||
"solution": "Use select_related when querying Orders with OrderItems"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"code_suggestions": [
|
||||
{
|
||||
"model": "OrderItem",
|
||||
"current": "order = models.ForeignKey(Order, on_delete=models.CASCADE)",
|
||||
"suggested": "order = models.ForeignKey(Order, on_delete=models.CASCADE, db_index=True)"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Limitations and Boundaries
|
||||
1. You should not modify or execute code unless explicitly requested
|
||||
2. You should indicate when you need additional information to provide accurate insights
|
||||
3. You should acknowledge when a requested analysis is beyond your capabilities
|
||||
4. You should not make assumptions about implementation details not present in the provided models
|
||||
5. You should clearly distinguish between factual observations and recommendations
|
||||
|
||||
## Learning and Improvement
|
||||
You should continuously improve your analysis capabilities by:
|
||||
1. Learning from user feedback
|
||||
2. Staying updated on Django best practices
|
||||
3. Expanding your understanding of common model patterns
|
||||
4. Refining your insight generation to be more relevant and actionable
|
||||
|
||||
## Ethical Considerations
|
||||
1. Respect data privacy by not suggesting exposing sensitive information
|
||||
2. Provide balanced recommendations that consider security, performance, and usability
|
||||
3. Be transparent about the limitations of your analysis
|
||||
4. Avoid making judgments about the quality of code beyond objective metrics
|
||||
|
||||
## Technical Integration
|
||||
You will be integrated into a Django application as a service that:
|
||||
1. Receives requests through a REST API
|
||||
2. Has access to model definitions through Django's introspection capabilities
|
||||
3. Returns JSON responses that can be directly used by frontend components
|
||||
4. Maintains context across multiple related queries when session information is provided
|
||||
@ -1,161 +0,0 @@
|
||||
# تدريب وكيل محلل نماذج Django بالعربية
|
||||
|
||||
## هدف الوكيل
|
||||
أنت وكيل ذكاء اصطناعي متخصص مصمم لتحليل نماذج Django وتقديم معلومات مفيدة للمستخدمين. وظيفتك الأساسية هي تفسير هياكل نماذج Django والعلاقات والبيانات الوصفية لتوليد رؤى ذات معنى تساعد المطورين وأصحاب المصلحة على فهم نماذج البيانات الخاصة بهم بشكل أفضل.
|
||||
|
||||
## القدرات الأساسية
|
||||
1. تحليل وفهم تعريفات نماذج Django
|
||||
2. تحديد العلاقات بين النماذج (ForeignKey, ManyToMany, OneToOne)
|
||||
3. تحليل حقول النموذج وأنواعها والقيود والبيانات الوصفية
|
||||
4. توليد إحصائيات ورؤى حول استخدام النموذج وهيكله
|
||||
5. تقديم توصيات لتحسين النموذج
|
||||
6. الاستجابة للاستعلامات باللغة الطبيعية حول النماذج
|
||||
7. تنسيق الردود كـ JSON منظم للتكامل مع تطبيقات الواجهة الأمامية
|
||||
|
||||
## معالجة المدخلات
|
||||
ستتلقى المدخلات بالتنسيق التالي:
|
||||
1. كود نموذج Django أو مراجع لملفات النموذج
|
||||
2. استعلام باللغة الطبيعية يحدد نوع التحليل أو الرؤى المطلوبة
|
||||
3. سياق اختياري حول المشروع أو مخاوف محددة
|
||||
|
||||
## متطلبات المخرجات
|
||||
يجب أن تكون ردودك:
|
||||
1. منسقة كـ JSON صالح
|
||||
2. تتضمن حقل "status" يشير إلى النجاح أو الفشل
|
||||
3. توفر مصفوفة "insights" تحتوي على التحليل المطلوب
|
||||
4. تتضمن بيانات وصفية حول التحليل الذي تم إجراؤه
|
||||
5. منظمة بطريقة يسهل تحليلها وعرضها في واجهة أمامية
|
||||
|
||||
## أنواع التحليل
|
||||
يجب أن تكون قادرًا على إجراء الأنواع التالية من التحليل:
|
||||
|
||||
### التحليل الهيكلي
|
||||
- عدد النماذج ومقاييس التعقيد
|
||||
- توزيع أنواع الحقول
|
||||
- رسم خرائط العلاقات وبيانات التصور
|
||||
- أنماط الوراثة
|
||||
- استخدام النماذج المجردة
|
||||
|
||||
### تحليل الأداء
|
||||
- اختناقات الاستعلام المحتملة
|
||||
- توصيات الفهرس المفقود
|
||||
- اقتراحات تحسين العلاقة
|
||||
- كشف ضعف استعلام N+1
|
||||
|
||||
### تحليل الأمان
|
||||
- كشف الحقول الحساسة
|
||||
- توصيات نموذج الإذن
|
||||
- تقييم مخاطر التعرض للبيانات
|
||||
|
||||
### تحليل سلامة البيانات
|
||||
- تحليل القيود
|
||||
- تقييم قواعد التحقق
|
||||
- توصيات اتساق البيانات
|
||||
|
||||
## أمثلة على التفاعلات
|
||||
|
||||
### مثال 1: تحليل النموذج الأساسي
|
||||
**استعلام المدخلات:**
|
||||
"قم بتحليل نماذج المستخدم والملف الشخصي وأظهر لي هيكل العلاقة بينهما."
|
||||
|
||||
**الرد المتوقع:**
|
||||
```json
|
||||
{
|
||||
"status": "نجاح",
|
||||
"request_id": "a1b2c3d4",
|
||||
"timestamp": "2025-05-25T23:21:56Z",
|
||||
"insights": [
|
||||
{
|
||||
"type": "تحليل_العلاقات",
|
||||
"models": ["User", "Profile"],
|
||||
"relationships": [
|
||||
{
|
||||
"from": "Profile",
|
||||
"to": "User",
|
||||
"type": "OneToOne",
|
||||
"field": "user",
|
||||
"related_name": "profile",
|
||||
"on_delete": "CASCADE"
|
||||
}
|
||||
],
|
||||
"visualization_data": {
|
||||
"nodes": [...],
|
||||
"edges": [...]
|
||||
}
|
||||
}
|
||||
],
|
||||
"recommendations": [
|
||||
"فكر في إضافة فهرس إلى Profile.user للبحث الأسرع"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### مثال 2: تحليل أداء الاستعلام
|
||||
**استعلام المدخلات:**
|
||||
"حدد مشاكل الأداء المحتملة في نماذج الطلب وعناصر الطلب."
|
||||
|
||||
**الرد المتوقع:**
|
||||
```json
|
||||
{
|
||||
"status": "نجاح",
|
||||
"request_id": "e5f6g7h8",
|
||||
"timestamp": "2025-05-25T23:22:30Z",
|
||||
"insights": [
|
||||
{
|
||||
"type": "تحليل_الأداء",
|
||||
"models": ["Order", "OrderItem"],
|
||||
"issues": [
|
||||
{
|
||||
"severity": "عالية",
|
||||
"model": "OrderItem",
|
||||
"field": "order",
|
||||
"issue": "فهرس قاعدة بيانات مفقود على ForeignKey",
|
||||
"impact": "استعلامات بطيئة عند تصفية OrderItems حسب Order",
|
||||
"solution": "أضف db_index=True إلى حقل order"
|
||||
},
|
||||
{
|
||||
"severity": "متوسطة",
|
||||
"model": "Order",
|
||||
"issue": "لا يوجد select_related في الاستعلامات الشائعة",
|
||||
"impact": "مشاكل استعلام N+1 محتملة",
|
||||
"solution": "استخدم select_related عند الاستعلام عن Orders مع OrderItems"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"code_suggestions": [
|
||||
{
|
||||
"model": "OrderItem",
|
||||
"current": "order = models.ForeignKey(Order, on_delete=models.CASCADE)",
|
||||
"suggested": "order = models.ForeignKey(Order, on_delete=models.CASCADE, db_index=True)"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## القيود والحدود
|
||||
1. لا يجب عليك تعديل أو تنفيذ التعليمات البرمجية ما لم يُطلب منك ذلك صراحةً
|
||||
2. يجب أن تشير عندما تحتاج إلى معلومات إضافية لتقديم رؤى دقيقة
|
||||
3. يجب أن تعترف عندما يكون التحليل المطلوب خارج قدراتك
|
||||
4. لا يجب أن تفترض تفاصيل التنفيذ غير الموجودة في النماذج المقدمة
|
||||
5. يجب أن تميز بوضوح بين الملاحظات الواقعية والتوصيات
|
||||
|
||||
## التعلم والتحسين
|
||||
يجب أن تحسن باستمرار قدرات التحليل الخاصة بك من خلال:
|
||||
1. التعلم من تعليقات المستخدم
|
||||
2. البقاء على اطلاع بأفضل ممارسات Django
|
||||
3. توسيع فهمك لأنماط النموذج الشائعة
|
||||
4. تحسين توليد الرؤى لتكون أكثر صلة وقابلية للتنفيذ
|
||||
|
||||
## الاعتبارات الأخلاقية
|
||||
1. احترام خصوصية البيانات من خلال عدم اقتراح كشف المعلومات الحساسة
|
||||
2. تقديم توصيات متوازنة تراعي الأمان والأداء وسهولة الاستخدام
|
||||
3. الشفافية بشأن حدود تحليلك
|
||||
4. تجنب إصدار أحكام حول جودة الكود بما يتجاوز المقاييس الموضوعية
|
||||
|
||||
## التكامل التقني
|
||||
سيتم دمجك في تطبيق Django كخدمة:
|
||||
1. تتلقى الطلبات من خلال واجهة برمجة تطبيقات REST
|
||||
2. لديها إمكانية الوصول إلى تعريفات النموذج من خلال قدرات التفتيش الذاتي لـ Django
|
||||
3. تعيد استجابات JSON التي يمكن استخدامها مباشرة بواسطة مكونات الواجهة الأمامية
|
||||
4. تحافظ على السياق عبر استعلامات متعددة ذات صلة عند توفير معلومات الجلسة
|
||||
@ -1,8 +1,8 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = "haikalbot"
|
||||
# app_name = "haikalbot"
|
||||
|
||||
urlpatterns = [
|
||||
path("analyze/", views.ModelAnalystView.as_view(), name="haikalbot"),
|
||||
path("bot/", views.HaikalBot.as_view(), name="haikalbot"),
|
||||
]
|
||||
|
||||
66
haikalbot/utils/export.py
Normal file
66
haikalbot/utils/export.py
Normal file
@ -0,0 +1,66 @@
|
||||
from django.http import HttpResponse
|
||||
import pandas as pd
|
||||
from io import BytesIO, StringIO
|
||||
|
||||
|
||||
def export_to_excel(self, data, filename):
|
||||
"""
|
||||
Export data to Excel format.
|
||||
|
||||
Args:
|
||||
data: Data to export
|
||||
filename: Base filename without extension
|
||||
|
||||
Returns:
|
||||
HttpResponse: Response with Excel file
|
||||
"""
|
||||
|
||||
|
||||
# Convert data to DataFrame
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# Create Excel file in memory
|
||||
excel_file = BytesIO()
|
||||
with pd.ExcelWriter(excel_file, engine='xlsxwriter') as writer:
|
||||
df.to_excel(writer, sheet_name='Model Analysis', index=False)
|
||||
|
||||
# Auto-adjust columns width
|
||||
worksheet = writer.sheets['Model Analysis']
|
||||
for i, col in enumerate(df.columns):
|
||||
max_width = max(df[col].astype(str).map(len).max(), len(col)) + 2
|
||||
worksheet.set_column(i, i, max_width)
|
||||
|
||||
# Set up response
|
||||
excel_file.seek(0)
|
||||
response = HttpResponse(
|
||||
excel_file.read(),
|
||||
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}.xlsx"'
|
||||
return response
|
||||
|
||||
|
||||
def export_to_csv(self, data, filename):
|
||||
"""
|
||||
Export data to CSV format.
|
||||
|
||||
Args:
|
||||
data: Data to export
|
||||
filename: Base filename without extension
|
||||
|
||||
Returns:
|
||||
HttpResponse: Response with CSV file
|
||||
"""
|
||||
|
||||
|
||||
# Convert data to DataFrame
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# Create CSV file in memory
|
||||
csv_file = StringIO()
|
||||
df.to_csv(csv_file, index=False)
|
||||
|
||||
# Set up response
|
||||
response = HttpResponse(csv_file.getvalue(), content_type='text/csv')
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}.csv"'
|
||||
return response
|
||||
@ -1,253 +1,63 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.shortcuts import render
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.http import JsonResponse
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
import json
|
||||
import hashlib
|
||||
from django.shortcuts import render
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views import View
|
||||
import logging
|
||||
import uuid
|
||||
import re
|
||||
|
||||
from inventory import models as inventory_models
|
||||
from inventory.utils import get_user_type
|
||||
from .models import AnalysisCache
|
||||
from .services.llm_service import get_llm_chain
|
||||
from .services.analysis_service import (
|
||||
generate_model_insight,
|
||||
generate_count_insight,
|
||||
generate_relationship_insight,
|
||||
generate_performance_insight,
|
||||
generate_statistics_insight,
|
||||
generate_recommendations
|
||||
)
|
||||
from .services.cache_service import CacheService
|
||||
from .utils.response_formatter import format_response
|
||||
from .ai_agent import analyze_prompt
|
||||
from .utils.export import export_to_excel, export_to_csv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class ModelAnalystView(View):
|
||||
"""
|
||||
View for handling model analysis requests and rendering the chatbot interface.
|
||||
|
||||
This view provides both GET and POST methods:
|
||||
- GET: Renders the chatbot interface
|
||||
- POST: Processes analysis requests and returns JSON responses
|
||||
|
||||
The view includes caching, permission checking, and multilingual support.
|
||||
"""
|
||||
# Configuration settings (can be moved to Django settings)
|
||||
CACHE_DURATION = getattr(settings, 'ANALYSIS_CACHE_DURATION', 3600)
|
||||
DEFAULT_LANGUAGE = getattr(settings, 'DEFAULT_LANGUAGE', 'en')
|
||||
|
||||
class HaikalBot(LoginRequiredMixin, View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""
|
||||
Render the chatbot interface.
|
||||
|
||||
:param request: The HTTP request
|
||||
:return: Rendered chatbot.html template
|
||||
Render the chat interface.
|
||||
"""
|
||||
context = {
|
||||
'dark_mode': request.session.get('dark_mode', False)
|
||||
'dark_mode': request.session.get('dark_mode', False),
|
||||
'page_title': _('AI Assistant')
|
||||
}
|
||||
return render(request, "haikalbot/chatbot.html", context)
|
||||
return render(request, "haikalbot/chat.html", context)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
Process analysis requests and return JSON responses.
|
||||
|
||||
:param request: The HTTP request containing the prompt
|
||||
:return: JsonResponse with analysis results
|
||||
Process the prompt and return results.
|
||||
"""
|
||||
prompt = request.POST.get("prompt")
|
||||
export = request.POST.get("export")
|
||||
language = request.POST.get("language", request.LANGUAGE_CODE)
|
||||
|
||||
if not prompt:
|
||||
error_msg = _("Prompt is required.") if language != "ar" else "الاستعلام مطلوب."
|
||||
return JsonResponse({"status": "error", "error": error_msg}, status=400)
|
||||
|
||||
try:
|
||||
# Parse request data
|
||||
data = json.loads(request.body)
|
||||
prompt = data.get('prompt')
|
||||
language = data.get('language', self.DEFAULT_LANGUAGE)
|
||||
dealer = get_user_type(request)
|
||||
result = analyze_prompt(prompt)
|
||||
|
||||
# Validate request
|
||||
if not prompt:
|
||||
error_msg = "الاستعلام مطلوب" if language == 'ar' else "Prompt is required"
|
||||
return self._error_response(error_msg, 400)
|
||||
# Handle export requests if data is available
|
||||
if export and result.get("status") == "success" and result.get("data"):
|
||||
try:
|
||||
if export == "excel":
|
||||
return export_to_excel(result["data"])
|
||||
elif export == "csv":
|
||||
return export_to_csv(result["data"])
|
||||
except Exception as e:
|
||||
logger.error(f"Export error: {e}")
|
||||
result["export_error"] = str(e)
|
||||
|
||||
if not self._check_permissions(dealer.id):
|
||||
error_msg = "تم رفض الإذن" if language == 'ar' else "Permission denied"
|
||||
return self._error_response(error_msg, 403)
|
||||
return JsonResponse(result, safe=False)
|
||||
|
||||
# Check cache
|
||||
cache_service = CacheService()
|
||||
prompt_hash = cache_service.generate_hash(prompt, dealer.id, language)
|
||||
cached_result = cache_service.get_cached_result(prompt_hash, request.user, dealer.id)
|
||||
|
||||
if cached_result:
|
||||
return JsonResponse(cached_result)
|
||||
|
||||
# Process prompt and generate insights
|
||||
insights = self._process_prompt(prompt, dealer, language)
|
||||
|
||||
# Cache results
|
||||
cache_service.cache_result(
|
||||
prompt_hash,
|
||||
insights,
|
||||
request.user,
|
||||
dealer.id,
|
||||
self.CACHE_DURATION
|
||||
)
|
||||
|
||||
return JsonResponse(insights)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
error_msg = "بيانات JSON غير صالحة في نص الطلب" if language == 'ar' else "Invalid JSON in request body"
|
||||
return self._error_response(error_msg, 400)
|
||||
except Exception as e:
|
||||
logger.exception("Error processing model analysis request")
|
||||
error_msg = f"حدث خطأ: {str(e)}" if language == 'ar' else f"An error occurred: {str(e)}"
|
||||
return self._error_response(error_msg, 500)
|
||||
logger.exception(f"Error processing prompt: {e}")
|
||||
error_msg = _("An error occurred while processing your request.")
|
||||
if language == "ar":
|
||||
error_msg = "حدث خطأ أثناء معالجة طلبك."
|
||||
|
||||
def _error_response(self, message, status):
|
||||
"""
|
||||
Create a standardized error response.
|
||||
|
||||
:param message: Error message
|
||||
:param status: HTTP status code
|
||||
:return: JsonResponse with error details
|
||||
"""
|
||||
return JsonResponse({"status": "error", "message": message}, status=status)
|
||||
|
||||
def _check_permissions(self, dealer_id):
|
||||
"""
|
||||
Check if the dealer has permissions to access the analysis.
|
||||
|
||||
:param dealer_id: ID of the dealer
|
||||
:return: True if dealer has permissions, False otherwise
|
||||
"""
|
||||
try:
|
||||
return inventory_models.Dealer.objects.filter(id=dealer_id).exists()
|
||||
except Exception:
|
||||
logger.exception("Error checking permissions")
|
||||
return False
|
||||
|
||||
def _process_prompt(self, prompt, dealer, language):
|
||||
"""
|
||||
Process the prompt and generate insights.
|
||||
|
||||
:param prompt: User's prompt text
|
||||
:param dealer: Dealer object
|
||||
:param language: Language code (e.g., 'en', 'ar')
|
||||
:return: Dictionary with analysis results
|
||||
"""
|
||||
# Initialize response structure
|
||||
response = format_response(
|
||||
prompt=prompt,
|
||||
language=language,
|
||||
request_id=str(uuid.uuid4()),
|
||||
timestamp=timezone.now().isoformat()
|
||||
)
|
||||
|
||||
# Get LLM chain for prompt analysis
|
||||
chain = get_llm_chain(language=language)
|
||||
|
||||
# Parse prompt using LLM
|
||||
if chain:
|
||||
try:
|
||||
result = chain.invoke({"prompt": prompt})
|
||||
json_match = re.search(r'({.*})', result.replace('\n', ' '), re.DOTALL)
|
||||
result = json.loads(json_match.group(1)) if json_match else {}
|
||||
except Exception as e:
|
||||
logger.error(f"LLM error fallback: {e}")
|
||||
result = {}
|
||||
else:
|
||||
result = {}
|
||||
|
||||
# Extract analysis parameters
|
||||
analysis_type = result.get('analysis_type', 'general')
|
||||
target_models = result.get('target_models', [])
|
||||
query_params = result.get('query_params', {})
|
||||
|
||||
# Get models to analyze
|
||||
all_models = list(apps.get_models())
|
||||
models_to_analyze = self._filter_models(all_models, target_models)
|
||||
if dealer:
|
||||
models_to_analyze = self._filter_by_dealer(models_to_analyze, dealer.id)
|
||||
|
||||
# Select analysis method based on type
|
||||
analysis_method = {
|
||||
'count': generate_count_insight,
|
||||
'relationship': generate_relationship_insight,
|
||||
'performance': generate_performance_insight,
|
||||
'statistics': generate_statistics_insight
|
||||
}.get(analysis_type, self._generate_model_insight_all)
|
||||
|
||||
# Generate insights
|
||||
insights = analysis_method(models_to_analyze, query_params, dealer.id if dealer else None, language)
|
||||
|
||||
# Add insights to response
|
||||
insights_key = "التحليلات" if language == 'ar' else "insights"
|
||||
if isinstance(insights, list):
|
||||
response[insights_key].extend(insights)
|
||||
else:
|
||||
response[insights_key].append(insights)
|
||||
|
||||
# Generate recommendations
|
||||
recommendations = generate_recommendations(models_to_analyze, analysis_type, language)
|
||||
if recommendations:
|
||||
recs_key = "التوصيات" if language == 'ar' else "recommendations"
|
||||
response[recs_key] = recommendations
|
||||
|
||||
# Add plain text summary for response
|
||||
summary_lines = []
|
||||
for insight in response[insights_key]:
|
||||
if isinstance(insight, dict):
|
||||
summary_lines.append(insight.get('type', 'Insight'))
|
||||
else:
|
||||
summary_lines.append(str(insight))
|
||||
|
||||
response['response'] = "\n".join(summary_lines)
|
||||
|
||||
return response
|
||||
|
||||
def _filter_models(self, all_models, target_models):
|
||||
"""
|
||||
Filter models based on target model names.
|
||||
|
||||
:param all_models: List of all available models
|
||||
:param target_models: List of target model names
|
||||
:return: Filtered list of models
|
||||
"""
|
||||
if not target_models:
|
||||
return all_models
|
||||
return [m for m in all_models if m.__name__ in target_models or
|
||||
m.__name__.lower() in [t.lower() for t in target_models]]
|
||||
|
||||
def _filter_by_dealer(self, models, dealer_id):
|
||||
"""
|
||||
Filter models that are relevant to the dealer.
|
||||
|
||||
:param models: List of models
|
||||
:param dealer_id: ID of the dealer
|
||||
:return: Filtered list of models
|
||||
"""
|
||||
dealer_models = [m for m in models if any(f.name in ('dealer', 'dealer_id')
|
||||
for f in m._meta.fields)]
|
||||
return dealer_models if dealer_models else models
|
||||
|
||||
def _generate_model_insight_all(self, models, query_params, dealer_id, language):
|
||||
"""
|
||||
Generate insights for all models.
|
||||
|
||||
:param models: List of models
|
||||
:param query_params: Query parameters
|
||||
:param dealer_id: ID of the dealer
|
||||
:param language: Language code
|
||||
:return: List of insights
|
||||
"""
|
||||
return [generate_model_insight(m, dealer_id, language) for m in models]
|
||||
return JsonResponse({
|
||||
"status": "error",
|
||||
"error": error_msg,
|
||||
"details": str(e) if request.user.is_staff else None
|
||||
}, status=500)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1543
inventory/management/commands/claude.py
Normal file
1543
inventory/management/commands/claude.py
Normal file
File diff suppressed because it is too large
Load Diff
124
inventory/management/commands/deepseek.py
Normal file
124
inventory/management/commands/deepseek.py
Normal file
@ -0,0 +1,124 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django_ledger.models import EntityModel, ChartOfAccountModel, AccountModel
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Creates Chart of Accounts for Deepseek entity'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get or create admin user
|
||||
admin_user = User.objects.filter(is_superuser=True).first()
|
||||
if not admin_user:
|
||||
self.stdout.write(self.style.ERROR('No admin user found!'))
|
||||
return
|
||||
|
||||
# Get or create Deepseek entity with minimal valid fields
|
||||
entity, created = EntityModel.objects.get_or_create(
|
||||
name='Deepseek',
|
||||
defaults={
|
||||
'admin': admin_user,
|
||||
'fy_start_month': 1, # January fiscal year start
|
||||
'accrual_method': True # Using accrual accounting
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
self.stdout.write(self.style.SUCCESS('Created Deepseek entity'))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS('Deepseek entity already exists'))
|
||||
|
||||
# Get or create Chart of Accounts
|
||||
coa, created = ChartOfAccountModel.objects.get_or_create(
|
||||
slug='deepseek-coa',
|
||||
defaults={
|
||||
'name': 'Deepseek Chart of Accounts',
|
||||
'entity': entity,
|
||||
'description': 'Standard COA for Deepseek automotive business'
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
self.stdout.write(self.style.SUCCESS('Created Chart of Accounts'))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS('Chart of Accounts already exists'))
|
||||
|
||||
# Account definitions with string role literals
|
||||
ACCOUNTS = [
|
||||
# Assets
|
||||
('1010', 'Cash on Hand', 'الصندوق', 'asset_ca_cash'),
|
||||
('1020', 'Bank', 'البنك', 'asset_ca_cash'),
|
||||
('1030', 'Accounts Receivable', 'العملاء', 'asset_ca_receivable'),
|
||||
('1040', 'Inventory (Cars)', 'مخزون السيارات', 'asset_ca_inventory'),
|
||||
('1045', 'Spare Parts Inventory', 'مخزون قطع الغيار', 'asset_ca_inventory'),
|
||||
('1050', 'Employee Advances', 'سُلف وأمانات الموظفين', 'asset_ca_other'),
|
||||
('1060', 'Prepaid Expenses', 'مصروفات مدفوعة مقدماً', 'asset_ca_other'),
|
||||
('1070', 'Notes Receivable', 'أوراق القبض', 'asset_ca_receivable'),
|
||||
('2010', 'Lands', 'أراضي', 'asset_ppe_land'),
|
||||
('2011', 'Buildings', 'مباني', 'asset_ppe_building'),
|
||||
('2012', 'Company Vehicles', 'سيارات الشركة', 'asset_ppe_equipment'),
|
||||
('2013', 'Equipment & Tools', 'أجهزة ومعدات', 'asset_ppe_equipment'),
|
||||
('2014', 'Furniture & Fixtures', 'أثاث وديكور', 'asset_ppe_equipment'),
|
||||
('2015', 'Other Fixed Assets', 'أصول ثابتة أخرى', 'asset_ppe_other'),
|
||||
('2020', 'Long-term Investments', 'استثمارات طويلة الأجل', 'asset_lt_investments'),
|
||||
('2030', 'Intangible Assets', 'أصول غير ملموسة', 'asset_lt_intangible'),
|
||||
|
||||
# Liabilities
|
||||
('3010', 'Accounts Payable', 'الموردين', 'liability_cl_acc_payable'),
|
||||
('3020', 'Notes Payable', 'أوراق الدفع', 'liability_cl_acc_payable'),
|
||||
('3030', 'Short-term Loans', 'قروض قصيرة الأجل', 'liability_cl_debt'),
|
||||
('3040', 'Employee Payables', 'السلف المستحقة', 'liability_cl_acc_payable'),
|
||||
('3050', 'Accrued Expenses', 'مصروفات مستحقة', 'liability_cl_acc_expense'),
|
||||
('3060', 'Accrued Taxes', 'ضرائب مستحقة', 'liability_cl_acc_expense'),
|
||||
('3070', 'Provisions', 'مخصصات', 'liability_cl_acc_expense'),
|
||||
('4010', 'Long-term Bank Loans', 'قروض طويلة الأجل', 'liability_lt_loans'),
|
||||
('4020', 'Lease Liabilities', 'التزامات تمويلية', 'liability_lt_loans'),
|
||||
('4030', 'Other Long-term Liabilities', 'التزامات أخرى طويلة الأجل', 'liability_lt_other'),
|
||||
|
||||
# Equity
|
||||
('5010', 'Capital', 'رأس المال', 'equity_capital'),
|
||||
('5020', 'Statutory Reserve', 'الاحتياطي القانوني', 'equity_retained'),
|
||||
('5030', 'Retained Earnings', 'احتياطي الأرباح', 'equity_retained'),
|
||||
('5040', 'Profit & Loss for the Period', 'أرباح وخسائر الفترة', 'equity_earnings'),
|
||||
|
||||
# Income
|
||||
('6010', 'Car Sales', 'مبيعات السيارات', 'income_operating'),
|
||||
('6020', 'After-Sales Services', 'إيرادات خدمات ما بعد البيع', 'income_operating'),
|
||||
('6030', 'Car Rental Income', 'إيرادات تأجير سيارات', 'income_operating'),
|
||||
('6040', 'Other Income', 'إيرادات أخرى', 'income_other'),
|
||||
|
||||
# Expenses
|
||||
('7010', 'Cost of Goods Sold', 'تكلفة البضاعة المباعة', 'expense_cogs'),
|
||||
('7015', 'Spare Parts Cost Consumed', 'تكلفة قطع الغيار المستهلكة', 'expense_cogs'),
|
||||
('7020', 'Salaries & Wages', 'رواتب وأجور', 'expense_operating'),
|
||||
('7030', 'Rent', 'إيجار', 'expense_operating'),
|
||||
('7040', 'Utilities', 'كهرباء ومياه', 'expense_operating'),
|
||||
('7050', 'Advertising & Marketing', 'دعاية وإعلان', 'expense_operating'),
|
||||
('7060', 'Maintenance', 'صيانة', 'expense_operating'),
|
||||
('7070', 'Operating Expenses', 'مصاريف تشغيلية', 'expense_operating'),
|
||||
('7080', 'Depreciation', 'استهلاك أصول ثابتة', 'expense_depreciation'),
|
||||
('7090', 'Fees & Taxes', 'رسوم وضرائب', 'expense_operating'),
|
||||
('7100', 'Bank Charges', 'مصاريف بنكية', 'expense_operating'),
|
||||
('7110', 'Other Expenses', 'مصاريف أخرى', 'expense_other'),
|
||||
]
|
||||
|
||||
# Create accounts
|
||||
created_count = 0
|
||||
for code, name_en, name_ar, role in ACCOUNTS:
|
||||
acc, created = AccountModel.objects.get_or_create(
|
||||
coa=coa,
|
||||
code=code,
|
||||
defaults={
|
||||
'name': name_en,
|
||||
'name_ar': name_ar,
|
||||
'role_default': role,
|
||||
'active': True,
|
||||
'balance_type': 'debit' if role.startswith(('asset', 'expense')) else 'credit'
|
||||
}
|
||||
)
|
||||
if created:
|
||||
created_count += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'Successfully created {created_count} accounts'))
|
||||
self.stdout.write(self.style.SUCCESS(f'Total accounts in COA: {len(ACCOUNTS)}'))
|
||||
127
inventory/management/commands/gemini.py
Normal file
127
inventory/management/commands/gemini.py
Normal file
@ -0,0 +1,127 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django_ledger.models import AccountModel, EntityModel, AccountRole, AccountType # تم التعديل هنا
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'ينشئ أو يحدث حسابات Django Ledger المحددة لكيان معين.'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
# إضافة وسيط لتحديد اسم الكيان
|
||||
parser.add_argument(
|
||||
'--entity_name',
|
||||
type=str,
|
||||
default='qemini', # اسم الكيان الافتراضي
|
||||
help='اسم الكيان الذي سيتم ربط الحسابات به (افتراضي: qemini).'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
entity_name = options['entity_name']
|
||||
|
||||
# البحث عن الكيان بناءً على الاسم المقدم
|
||||
try:
|
||||
entity = EntityModel.objects.get(name__iexact=entity_name)
|
||||
self.stdout.write(self.style.SUCCESS(f"تم العثور على الكيان: {entity.name}"))
|
||||
except EntityModel.DoesNotExist:
|
||||
raise CommandError(f"لم يتم العثور على كيان باسم '{entity_name}'. يرجى التأكد من وجوده.")
|
||||
except Exception as e:
|
||||
raise CommandError(f"حدث خطأ أثناء جلب الكيان '{entity_name}': {e}")
|
||||
|
||||
# قائمة الحسابات المراد إنشاؤها
|
||||
accounts_data = [
|
||||
# الأصول (مدين)
|
||||
{'code': '1010', 'name': 'Cash on Hand', 'role': AccountRole.CASH, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '1020', 'name': 'Bank', 'role': AccountRole.CASH, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '1030', 'name': 'Accounts Receivable', 'role': AccountRole.RECEIVABLE, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '1040', 'name': 'Inventory (Cars)', 'role': AccountRole.INVENTORY, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '1045', 'name': 'Spare Parts Inventory', 'role': AccountRole.INVENTORY, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '1050', 'name': 'Employee Advances', 'role': AccountRole.OTHER_ASSET, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '1060', 'name': 'Prepaid Expenses', 'role': AccountRole.OTHER_ASSET, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '1070', 'name': 'Notes Receivable', 'role': AccountRole.RECEIVABLE, 'balance_type': AccountType.DEBIT},
|
||||
|
||||
{'code': '2010', 'name': 'Lands', 'role': AccountRole.FIXED_ASSET, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '2011', 'name': 'Buildings', 'role': AccountRole.FIXED_ASSET, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '2012', 'name': 'Company Vehicles', 'role': AccountRole.FIXED_ASSET, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '2013', 'name': 'Equipment & Tools', 'role': AccountRole.FIXED_ASSET, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '2014', 'name': 'Furniture & Fixtures', 'role': AccountRole.FIXED_ASSET, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '2015', 'name': 'Other Fixed Assets', 'role': AccountRole.FIXED_ASSET, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '2020', 'name': 'Long-term Investments', 'role': AccountRole.OTHER_ASSET, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '2030', 'name': 'Intangible Assets', 'role': AccountRole.INTANGIBLE_ASSET, 'balance_type': AccountType.DEBIT},
|
||||
|
||||
# الخصوم (دائن)
|
||||
{'code': '3010', 'name': 'Accounts Payable', 'role': AccountRole.PAYABLE, 'balance_type': AccountType.CREDIT},
|
||||
{'code': '3020', 'name': 'Notes Payable', 'role': AccountRole.PAYABLE, 'balance_type': AccountType.CREDIT},
|
||||
{'code': '3030', 'name': 'Short-term Loans', 'role': AccountRole.OTHER_LIABILITY, 'balance_type': AccountType.CREDIT},
|
||||
{'code': '3040', 'name': 'Employee Payables', 'role': AccountRole.OTHER_LIABILITY, 'balance_type': AccountType.CREDIT},
|
||||
{'code': '3050', 'name': 'Accrued Expenses', 'role': AccountRole.OTHER_LIABILITY, 'balance_type': AccountType.CREDIT},
|
||||
{'code': '3060', 'name': 'Accrued Taxes', 'role': AccountRole.OTHER_LIABILITY, 'balance_type': AccountType.CREDIT},
|
||||
{'code': '3070', 'name': 'Provisions', 'role': AccountRole.PROVISION, 'balance_type': AccountType.CREDIT},
|
||||
|
||||
{'code': '4010', 'name': 'Long-term Bank Loans', 'role': AccountRole.LONG_TERM_DEBT, 'balance_type': AccountType.CREDIT},
|
||||
{'code': '4020', 'name': 'Lease Liabilities', 'role': AccountRole.LONG_TERM_DEBT, 'balance_type': AccountType.CREDIT},
|
||||
{'code': '4030', 'name': 'Other Long-term Liabilities', 'role': AccountRole.LONG_TERM_DEBT, 'balance_type': AccountType.CREDIT},
|
||||
|
||||
# حقوق الملكية (دائن)
|
||||
{'code': '5010', 'name': 'Capital', 'role': AccountRole.EQUITY, 'balance_type': AccountType.CREDIT},
|
||||
{'code': '5020', 'name': 'Statutory Reserve', 'role': AccountRole.RETAINED_EARNINGS, 'balance_type': AccountType.CREDIT},
|
||||
{'code': '5030', 'name': 'Retained Earnings', 'role': AccountRole.RETAINED_EARNINGS, 'balance_type': AccountType.CREDIT},
|
||||
{'code': '5040', 'name': 'Profit & Loss for the Period', 'role': AccountRole.RETAINED_EARNINGS, 'balance_type': AccountType.CREDIT},
|
||||
|
||||
# الإيرادات (دائن)
|
||||
{'code': '6010', 'name': 'Car Sales', 'role': AccountRole.SALES, 'balance_type': AccountType.CREDIT},
|
||||
{'code': '6020', 'name': 'After-Sales Services', 'role': AccountRole.SALES, 'balance_type': AccountType.CREDIT},
|
||||
{'code': '6030', 'name': 'Car Rental Income', 'role': AccountRole.SALES, 'balance_type': AccountType.CREDIT},
|
||||
{'code': '6040', 'name': 'Other Income', 'role': AccountRole.OTHER_INCOME, 'balance_type': AccountType.CREDIT},
|
||||
|
||||
# المصروفات (مدين)
|
||||
{'code': '7010', 'name': 'Cost of Goods Sold', 'role': AccountRole.COST_OF_GOODS_SOLD, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '7015', 'name': 'Spare Parts Cost Consumed', 'role': AccountRole.COST_OF_GOODS_SOLD, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '7020', 'name': 'Salaries & Wages', 'role': AccountRole.OPERATING_EXPENSE, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '7030', 'name': 'Rent', 'role': AccountRole.OPERATING_EXPENSE, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '7040', 'name': 'Utilities', 'role': AccountRole.OPERATING_EXPENSE, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '7050', 'name': 'Advertising & Marketing', 'role': AccountRole.OPERATING_EXPENSE, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '7060', 'name': 'Maintenance', 'role': AccountRole.OPERATING_EXPENSE, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '7070', 'name': 'Operating Expenses', 'role': AccountRole.OPERATING_EXPENSE, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '7080', 'name': 'Depreciation', 'role': AccountRole.DEPRECIATION_EXPENSE, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '7090', 'name': 'Fees & Taxes', 'role': AccountRole.OTHER_EXPENSE, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '7100', 'name': 'Bank Charges', 'role': AccountRole.OTHER_EXPENSE, 'balance_type': AccountType.DEBIT},
|
||||
{'code': '7110', 'name': 'Other Expenses', 'role': AccountRole.OTHER_EXPENSE, 'balance_type': AccountType.DEBIT},
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
|
||||
for acc_data in accounts_data:
|
||||
try:
|
||||
# البحث عن الحساب الموجود أو إنشاء حساب جديد
|
||||
account, created = AccountModel.objects.get_or_create(
|
||||
entity=entity,
|
||||
code=acc_data['code'],
|
||||
defaults={
|
||||
'name': acc_data['name'],
|
||||
'role': acc_data['role'], # تم التعديل هنا
|
||||
'balance_type': acc_data['balance_type'] # تم التعديل هنا
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
created_count += 1
|
||||
self.stdout.write(self.style.SUCCESS(f"تم إنشاء الحساب: {account.code} - {account.name}"))
|
||||
else:
|
||||
# إذا كان الحساب موجودًا، نقوم بتحديث اسمه ودوره ونوع رصيده إذا كان مختلفًا
|
||||
if (account.name != acc_data['name'] or
|
||||
account.role != acc_data['role'] or # تم التعديل هنا
|
||||
account.balance_type != acc_data['balance_type']): # تم التعديل هنا
|
||||
account.name = acc_data['name']
|
||||
account.role = acc_data['role']
|
||||
account.balance_type = acc_data['balance_type']
|
||||
account.save()
|
||||
updated_count += 1
|
||||
self.stdout.write(self.style.WARNING(f"تم تحديث الحساب: {account.code} - {account.name}"))
|
||||
else:
|
||||
self.stdout.write(f"الحساب موجود بالفعل ولم يتطلب تحديث: {account.code} - {account.name}")
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"خطأ في معالجة الحساب {acc_data['code']} - {acc_data['name']}: {e}"))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"\nعملية إنشاء/تحديث الحسابات اكتملت."))
|
||||
self.stdout.write(self.style.SUCCESS(f"عدد الحسابات التي تم إنشاؤها حديثًا: {created_count}"))
|
||||
self.stdout.write(self.style.SUCCESS(f"عدد الحسابات التي تم تحديثها: {updated_count}"))
|
||||
123
inventory/management/commands/gpt.py
Normal file
123
inventory/management/commands/gpt.py
Normal file
@ -0,0 +1,123 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django_ledger.models.entity import EntityModel
|
||||
from django_ledger.models.chart_of_accounts import ChartOfAccountModel
|
||||
from django_ledger.models.accounts import AccountModel
|
||||
from django_ledger.models.roles import RoleModel
|
||||
from django_ledger.models.balance_types import BalanceTypeModel
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Create the chart of accounts for the ChatGPT entity'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Step 1: Retrieve or create the entity
|
||||
entity, created = EntityModel.objects.get_or_create(name="chatgpt")
|
||||
if created:
|
||||
self.stdout.write(self.style.SUCCESS("Entity 'chatgpt' created."))
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING("Entity 'chatgpt' already exists."))
|
||||
|
||||
# Step 2: Retrieve or create the default Chart of Accounts
|
||||
if hasattr(entity, 'default_coa') and entity.default_coa:
|
||||
coa = entity.default_coa
|
||||
self.stdout.write(self.style.WARNING("Default Chart of Accounts already exists."))
|
||||
else:
|
||||
coa = ChartOfAccountModel.objects.create(name="Default CoA", entity=entity)
|
||||
entity.default_coa = coa
|
||||
entity.save()
|
||||
self.stdout.write(self.style.SUCCESS("Default Chart of Accounts created."))
|
||||
|
||||
# Step 3: Define the accounts to be created
|
||||
accounts = [
|
||||
(1010, 'Cash on Hand', 'الصندوق', 'asset'),
|
||||
(1020, 'Bank', 'البنك', 'asset'),
|
||||
(1030, 'Accounts Receivable', 'العملاء', 'asset'),
|
||||
(1040, 'Inventory (Cars)', 'مخزون السيارات', 'asset'),
|
||||
(1045, 'Spare Parts Inventory', 'مخزون قطع الغيار', 'asset'),
|
||||
(1050, 'Employee Advances', 'سُلف وأمانات الموظفين', 'asset'),
|
||||
(1060, 'Prepaid Expenses', 'مصروفات مدفوعة مقدماً', 'asset'),
|
||||
(1070, 'Notes Receivable', 'أوراق القبض', 'asset'),
|
||||
(2010, 'Lands', 'أراضي', 'asset'),
|
||||
(2011, 'Buildings', 'مباني', 'asset'),
|
||||
(2012, 'Company Vehicles', 'سيارات الشركة', 'asset'),
|
||||
(2013, 'Equipment & Tools', 'أجهزة ومعدات', 'asset'),
|
||||
(2014, 'Furniture & Fixtures', 'أثاث وديكور', 'asset'),
|
||||
(2015, 'Other Fixed Assets', 'أصول ثابتة أخرى', 'asset'),
|
||||
(2020, 'Long-term Investments', 'استثمارات طويلة الأجل', 'asset'),
|
||||
(2030, 'Intangible Assets', 'أصول غير ملموسة', 'asset'),
|
||||
(3010, 'Accounts Payable', 'الموردين', 'liability'),
|
||||
(3020, 'Notes Payable', 'أوراق الدفع', 'liability'),
|
||||
(3030, 'Short-term Loans', 'قروض قصيرة الأجل', 'liability'),
|
||||
(3040, 'Employee Payables', 'السلف المستحقة', 'liability'),
|
||||
(3050, 'Accrued Expenses', 'مصروفات مستحقة', 'liability'),
|
||||
(3060, 'Accrued Taxes', 'ضرائب مستحقة', 'liability'),
|
||||
(3070, 'Provisions', 'مخصصات', 'liability'),
|
||||
(4010, 'Long-term Bank Loans', 'قروض طويلة الأجل', 'liability'),
|
||||
(4020, 'Lease Liabilities', 'التزامات تمويلية', 'liability'),
|
||||
(4030, 'Other Long-term Liabilities', 'التزامات أخرى طويلة الأجل', 'liability'),
|
||||
(5010, 'Capital', 'رأس المال', 'equity'),
|
||||
(5020, 'Statutory Reserve', 'الاحتياطي القانوني', 'equity'),
|
||||
(5030, 'Retained Earnings', 'احتياطي الأرباح', 'equity'),
|
||||
(5040, 'Profit & Loss for the Period', 'أرباح وخسائر الفترة', 'equity'),
|
||||
(6010, 'Car Sales', 'مبيعات السيارات', 'income'),
|
||||
(6020, 'After-Sales Services', 'إيرادات خدمات ما بعد البيع', 'income'),
|
||||
(6030, 'Car Rental Income', 'إيرادات تأجير سيارات', 'income'),
|
||||
(6040, 'Other Income', 'إيرادات أخرى', 'income'),
|
||||
(7010, 'Cost of Goods Sold', 'تكلفة البضاعة المباعة', 'expense'),
|
||||
(7015, 'Spare Parts Cost Consumed', 'تكلفة قطع الغيار المستهلكة', 'expense'),
|
||||
(7020, 'Salaries & Wages', 'رواتب وأجور', 'expense'),
|
||||
(7030, 'Rent', 'إيجار', 'expense'),
|
||||
(7040, 'Utilities', 'كهرباء ومياه', 'expense'),
|
||||
(7050, 'Advertising & Marketing', 'دعاية وإعلان', 'expense'),
|
||||
(7060, 'Maintenance', 'صيانة', 'expense'),
|
||||
(7070, 'Operating Expenses', 'مصاريف تشغيلية', 'expense'),
|
||||
(7080, 'Depreciation', 'استهلاك أصول ثابتة', 'expense'),
|
||||
(7090, 'Fees & Taxes', 'رسوم وضرائب', 'expense'),
|
||||
(7100, 'Bank Charges', 'مصاريف بنكية', 'expense'),
|
||||
(7110, 'Other Expenses', 'مصاريف أخرى', 'expense'),
|
||||
]
|
||||
|
||||
# Mapping for role and balance_type
|
||||
role_mapping = {
|
||||
'asset': 'ASSET',
|
||||
'liability': 'LIABILITY',
|
||||
'equity': 'EQUITY',
|
||||
'income': 'INCOME',
|
||||
'expense': 'EXPENSE',
|
||||
}
|
||||
|
||||
balance_type_mapping = {
|
||||
'ASSET': 'DEBIT',
|
||||
'LIABILITY': 'CREDIT',
|
||||
'EQUITY': 'CREDIT',
|
||||
'INCOME': 'CREDIT',
|
||||
'EXPENSE': 'DEBIT',
|
||||
}
|
||||
|
||||
# Step 4: Delete existing accounts in the CoA
|
||||
existing_accounts = AccountModel.objects.filter(coa_model=coa)
|
||||
count_deleted = existing_accounts.count()
|
||||
existing_accounts.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count_deleted} existing accounts."))
|
||||
|
||||
# Step 5: Create new accounts
|
||||
for code, name_en, name_ar, role_slug in accounts:
|
||||
role = RoleModel.objects.get(slug=role_slug)
|
||||
balance_type = BalanceTypeModel.objects.get(role=role)
|
||||
parent = None # Set to None for root accounts
|
||||
|
||||
# Determine parent-child relationships
|
||||
if code in [1040, 1045, 2012, 2013, 2014, 2015, 2020, 2030]:
|
||||
parent_code = code - 10 # Example logic for determining parent code
|
||||
parent = AccountModel.objects.get(coa_model=coa, code=parent_code)
|
||||
|
||||
account = AccountModel.objects.create(
|
||||
coa_model=coa,
|
||||
code=code,
|
||||
name=name_en,
|
||||
arabic_name=name_ar,
|
||||
role=role,
|
||||
balance_type=balance_type,
|
||||
parent=parent,
|
||||
depth=parent.depth + 1 if parent else 0,
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f"Account {name_en} ({code}) created."))
|
||||
76
inventory/management/commands/qwen.py
Normal file
76
inventory/management/commands/qwen.py
Normal file
@ -0,0 +1,76 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django_ledger.models import AccountModel
|
||||
from django_ledger.constants import ACCOUNT_TYPE_ASSET, ACCOUNT_TYPE_LIABILITY, ACCOUNT_TYPE_EQUITY, ACCOUNT_TYPE_INCOME, ACCOUNT_TYPE_EXPENSE
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Creates default accounts for the entity "qwen" in Django Ledger'
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
self.stdout.write('Creating accounts for entity "qwen"...')
|
||||
|
||||
accounts_data = [
|
||||
# Assets (Debit Balance)
|
||||
{'code': '1010', 'name_ar': 'الصندوق', 'name_en': 'Cash on Hand', 'type': ACCOUNT_TYPE_ASSET},
|
||||
{'code': '1020', 'name_ar': 'البنك', 'name_en': 'Bank', 'type': ACCOUNT_TYPE_ASSET},
|
||||
{'code': '1030', 'name_ar': 'العملاء', 'name_en': 'Accounts Receivable', 'type': ACCOUNT_TYPE_ASSET},
|
||||
{'code': '1040', 'name_ar': 'مخزون السيارات', 'name_en': 'Inventory (Cars)', 'type': ACCOUNT_TYPE_ASSET},
|
||||
{'code': '1045', 'name_ar': 'مخزون قطع الغيار', 'name_en': 'Spare Parts Inventory', 'type': ACCOUNT_TYPE_ASSET},
|
||||
{'code': '1050', 'name_ar': 'سُلف وأمانات الموظفين', 'name_en': 'Employee Advances', 'type': ACCOUNT_TYPE_ASSET},
|
||||
{'code': '1060', 'name_ar': 'مصروفات مدفوعة مقدماً', 'name_en': 'Prepaid Expenses', 'type': ACCOUNT_TYPE_ASSET},
|
||||
{'code': '1070', 'name_ar': 'أوراق القبض', 'name_en': 'Notes Receivable', 'type': ACCOUNT_TYPE_ASSET},
|
||||
|
||||
# Liabilities (Credit Balance)
|
||||
{'code': '3010', 'name_ar': 'الموردين', 'name_en': 'Accounts Payable', 'type': ACCOUNT_TYPE_LIABILITY},
|
||||
{'code': '3020', 'name_ar': 'أوراق الدفع', 'name_en': 'Notes Payable', 'type': ACCOUNT_TYPE_LIABILITY},
|
||||
{'code': '3030', 'name_ar': 'قروض قصيرة الأجل', 'name_en': 'Short-term Loans', 'type': ACCOUNT_TYPE_LIABILITY},
|
||||
{'code': '3040', 'name_ar': 'السلف المستحقة', 'name_en': 'Employee Payables', 'type': ACCOUNT_TYPE_LIABILITY},
|
||||
{'code': '3050', 'name_ar': 'مصروفات مستحقة', 'name_en': 'Accrued Expenses', 'type': ACCOUNT_TYPE_LIABILITY},
|
||||
{'code': '3060', 'name_ar': 'ضرائب مستحقة', 'name_en': 'Accrued Taxes', 'type': ACCOUNT_TYPE_LIABILITY},
|
||||
{'code': '3070', 'name_ar': 'مخصصات', 'name_en': 'Provisions', 'type': ACCOUNT_TYPE_LIABILITY},
|
||||
|
||||
# Equity (Credit Balance)
|
||||
{'code': '5010', 'name_ar': 'رأس المال', 'name_en': 'Capital', 'type': ACCOUNT_TYPE_EQUITY},
|
||||
{'code': '5020', 'name_ar': 'الاحتياطي القانوني', 'name_en': 'Statutory Reserve', 'type': ACCOUNT_TYPE_EQUITY},
|
||||
{'code': '5030', 'name_ar': 'احتياطي الأرباح', 'name_en': 'Retained Earnings', 'type': ACCOUNT_TYPE_EQUITY},
|
||||
{'code': '5040', 'name_ar': 'أرباح وخسائر الفترة', 'name_en': 'Profit & Loss for the Period', 'type': ACCOUNT_TYPE_EQUITY},
|
||||
|
||||
# Income (Revenue) (Credit Balance)
|
||||
{'code': '6010', 'name_ar': 'مبيعات السيارات', 'name_en': 'Car Sales', 'type': ACCOUNT_TYPE_INCOME},
|
||||
{'code': '6020', 'name_ar': 'إيرادات خدمات ما بعد البيع', 'name_en': 'After-Sales Services', 'type': ACCOUNT_TYPE_INCOME},
|
||||
{'code': '6030', 'name_ar': 'إيرادات تأجير سيارات', 'name_en': 'Car Rental Income', 'type': ACCOUNT_TYPE_INCOME},
|
||||
{'code': '6040', 'name_ar': 'إيرادات أخرى', 'name_en': 'Other Income', 'type': ACCOUNT_TYPE_INCOME},
|
||||
|
||||
# Expenses (Debit Balance)
|
||||
{'code': '7010', 'name_ar': 'تكلفة البضاعة المباعة', 'name_en': 'Cost of Goods Sold', 'type': ACCOUNT_TYPE_EXPENSE},
|
||||
{'code': '7015', 'name_ar': 'تكلفة قطع الغيار المستهلكة', 'name_en': 'Spare Parts Cost Consumed', 'type': ACCOUNT_TYPE_EXPENSE},
|
||||
{'code': '7020', 'name_ar': 'رواتب وأجور', 'name_en': 'Salaries & Wages', 'type': ACCOUNT_TYPE_EXPENSE},
|
||||
{'code': '7030', 'name_ar': 'إيجار', 'name_en': 'Rent', 'type': ACCOUNT_TYPE_EXPENSE},
|
||||
{'code': '7040', 'name_ar': 'كهرباء ومياه', 'name_en': 'Utilities', 'type': ACCOUNT_TYPE_EXPENSE},
|
||||
{'code': '7050', 'name_ar': 'دعاية وإعلان', 'name_en': 'Advertising & Marketing', 'type': ACCOUNT_TYPE_EXPENSE},
|
||||
{'code': '7060', 'name_ar': 'صيانة', 'name_en': 'Maintenance', 'type': ACCOUNT_TYPE_EXPENSE},
|
||||
{'code': '7070', 'name_ar': 'مصاريف تشغيلية', 'name_en': 'Operating Expenses', 'type': ACCOUNT_TYPE_EXPENSE},
|
||||
{'code': '7080', 'name_ar': 'استهلاك أصول ثابتة', 'name_en': 'Depreciation', 'type': ACCOUNT_TYPE_EXPENSE},
|
||||
{'code': '7090', 'name_ar': 'رسوم وضرائب', 'name_en': 'Fees & Taxes', 'type': ACCOUNT_TYPE_EXPENSE},
|
||||
{'code': '7100', 'name_ar': 'مصاريف بنكية', 'name_en': 'Bank Charges', 'type': ACCOUNT_TYPE_EXPENSE},
|
||||
{'code': '7110', 'name_ar': 'مصاريف أخرى', 'name_en': 'Other Expenses', 'type': ACCOUNT_TYPE_EXPENSE},
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
for acc in accounts_data:
|
||||
account, created = objects.get_or_create(
|
||||
code=acc['code'],
|
||||
defaults={
|
||||
'name': acc['name_ar'],
|
||||
'name_en': acc['name_en'],
|
||||
'account_type': acc['type'],
|
||||
'balance_type': BALANCE_TYPE_CREDIT if acc['type'] in [
|
||||
ACCOUNT_TYPE_LIABILITY,
|
||||
ACCOUNT_TYPE_EQUITY,
|
||||
ACCOUNT_TYPE_INCOME
|
||||
] else BALANCE_TYPE_DEBIT
|
||||
}
|
||||
)
|
||||
if created:
|
||||
created_count += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'Successfully created {created_count} accounts for "qwen".'))
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.1 on 2025-05-25 23:01
|
||||
# Generated by Django 5.1.7 on 2025-06-01 11:25
|
||||
|
||||
import datetime
|
||||
import django.core.validators
|
||||
@ -21,7 +21,7 @@ class Migration(migrations.Migration):
|
||||
('appointment', '0001_initial'),
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('django_ledger', '0001_initial'),
|
||||
('django_ledger', '0021_alter_bankaccountmodel_account_model_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
@ -155,6 +155,7 @@ class Migration(migrations.Migration):
|
||||
('cost_price', models.DecimalField(decimal_places=2, max_digits=14, verbose_name='Cost Price')),
|
||||
('selling_price', models.DecimalField(decimal_places=2, max_digits=14, verbose_name='Selling Price')),
|
||||
('discount_amount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=14, verbose_name='Discount Amount')),
|
||||
('is_sold', models.BooleanField(default=False)),
|
||||
('additional_services', models.ManyToManyField(blank=True, related_name='additional_finances', to='inventory.additionalservices')),
|
||||
('car', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='finances', to='inventory.car')),
|
||||
],
|
||||
@ -475,6 +476,36 @@ class Migration(migrations.Migration):
|
||||
'verbose_name_plural': 'Emails',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Lead',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('first_name', models.CharField(max_length=50, verbose_name='First Name')),
|
||||
('last_name', models.CharField(max_length=50, verbose_name='Last Name')),
|
||||
('email', models.EmailField(max_length=254, verbose_name='Email')),
|
||||
('phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region='SA', verbose_name='Phone Number')),
|
||||
('address', models.CharField(blank=True, max_length=200, null=True, verbose_name='Address')),
|
||||
('lead_type', models.CharField(choices=[('customer', 'Customer'), ('organization', 'Organization')], default='customer', max_length=50, verbose_name='Lead Type')),
|
||||
('source', models.CharField(choices=[('referrals', 'Referrals'), ('whatsapp', 'WhatsApp'), ('showroom', 'Showroom'), ('tiktok', 'TikTok'), ('instagram', 'Instagram'), ('x', 'X'), ('facebook', 'Facebook'), ('motory', 'Motory'), ('influencers', 'Influencers'), ('youtube', 'Youtube'), ('campaign', 'Campaign')], max_length=50, verbose_name='Source')),
|
||||
('channel', models.CharField(choices=[('walk_in', 'Walk In'), ('toll_free', 'Toll Free'), ('website', 'Website'), ('email', 'Email'), ('form', 'Form')], max_length=50, verbose_name='Channel')),
|
||||
('status', models.CharField(choices=[('new', 'New'), ('contacted', 'Contacted'), ('qualified', 'Qualified'), ('unqualified', 'Unqualified'), ('converted', 'Converted')], db_index=True, default='new', max_length=50, verbose_name='Status')),
|
||||
('next_action', models.CharField(blank=True, max_length=255, null=True, verbose_name='Next Action')),
|
||||
('next_action_date', models.DateTimeField(blank=True, null=True, verbose_name='Next Action Date')),
|
||||
('is_converted', models.BooleanField(default=False)),
|
||||
('converted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Created')),
|
||||
('updated', models.DateTimeField(auto_now=True, verbose_name='Updated')),
|
||||
('slug', models.SlugField(blank=True, null=True, unique=True)),
|
||||
('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='customer_leads', to='inventory.customer')),
|
||||
('dealer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='leads', to='inventory.dealer')),
|
||||
('id_car_make', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='inventory.carmake', verbose_name='Make')),
|
||||
('id_car_model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='inventory.carmodel', verbose_name='Model')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Lead',
|
||||
'verbose_name_plural': 'Leads',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Notes',
|
||||
fields=[
|
||||
@ -534,41 +565,41 @@ class Migration(migrations.Migration):
|
||||
bases=(models.Model, inventory.mixins.LocalizedNameMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Lead',
|
||||
name='Opportunity',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('first_name', models.CharField(max_length=50, verbose_name='First Name')),
|
||||
('last_name', models.CharField(max_length=50, verbose_name='Last Name')),
|
||||
('email', models.EmailField(max_length=254, verbose_name='Email')),
|
||||
('phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region='SA', verbose_name='Phone Number')),
|
||||
('lead_type', models.CharField(choices=[('customer', 'Customer'), ('organization', 'Organization')], default='customer', max_length=50, verbose_name='Lead Type')),
|
||||
('year', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Year')),
|
||||
('source', models.CharField(choices=[('referrals', 'Referrals'), ('whatsapp', 'WhatsApp'), ('showroom', 'Showroom'), ('tiktok', 'TikTok'), ('instagram', 'Instagram'), ('x', 'X'), ('facebook', 'Facebook'), ('motory', 'Motory'), ('influencers', 'Influencers'), ('youtube', 'Youtube'), ('campaign', 'Campaign')], max_length=50, verbose_name='Source')),
|
||||
('channel', models.CharField(choices=[('walk_in', 'Walk In'), ('toll_free', 'Toll Free'), ('website', 'Website'), ('email', 'Email'), ('form', 'Form')], max_length=50, verbose_name='Channel')),
|
||||
('crn', models.CharField(blank=True, max_length=10, null=True, unique=True, verbose_name='Commercial Registration Number')),
|
||||
('vrn', models.CharField(blank=True, max_length=15, null=True, unique=True, verbose_name='VAT Registration Number')),
|
||||
('address', models.CharField(max_length=50, verbose_name='address')),
|
||||
('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High')], default='medium', max_length=10, verbose_name='Priority')),
|
||||
('status', models.CharField(choices=[('new', 'New'), ('follow_up', 'Follow-up'), ('negotiation', 'Negotiation'), ('won', 'Won'), ('lost', 'Lost'), ('closed', 'Closed')], db_index=True, default='new', max_length=50, verbose_name='Status')),
|
||||
('next_action', models.CharField(blank=True, max_length=255, null=True, verbose_name='Next Action')),
|
||||
('next_action_date', models.DateTimeField(blank=True, null=True, verbose_name='Next Action Date')),
|
||||
('is_converted', models.BooleanField(default=False)),
|
||||
('converted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('salary', models.PositiveIntegerField(blank=True, null=True, verbose_name='Salary')),
|
||||
('created', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Created')),
|
||||
('crn', models.CharField(blank=True, max_length=20, null=True, verbose_name='CRN')),
|
||||
('vrn', models.CharField(blank=True, max_length=20, null=True, verbose_name='VRN')),
|
||||
('salary', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Salary')),
|
||||
('priority', models.CharField(choices=[('high', 'High'), ('medium', 'Medium'), ('low', 'Low')], default='medium', max_length=20, verbose_name='Priority')),
|
||||
('stage', models.CharField(choices=[('qualification', 'Qualification'), ('test_drive', 'Test Drive'), ('quotation', 'Quotation'), ('negotiation', 'Negotiation'), ('financing', 'Financing'), ('closed_won', 'Closed Won'), ('closed_lost', 'Closed Lost'), ('on_hold', 'On Hold')], max_length=20, verbose_name='Stage')),
|
||||
('probability', models.PositiveIntegerField(validators=[inventory.models.validate_probability])),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Amount')),
|
||||
('expected_revenue', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Expected Revenue')),
|
||||
('vehicle_of_interest_make', models.CharField(blank=True, max_length=50, null=True)),
|
||||
('vehicle_of_interest_model', models.CharField(blank=True, max_length=100, null=True)),
|
||||
('expected_close_date', models.DateField(blank=True, null=True)),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
|
||||
('updated', models.DateTimeField(auto_now=True, verbose_name='Updated')),
|
||||
('slug', models.SlugField(blank=True, null=True, unique=True)),
|
||||
('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='customer_leads', to='inventory.customer')),
|
||||
('dealer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='leads', to='inventory.dealer')),
|
||||
('id_car_make', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='inventory.carmake', verbose_name='Make')),
|
||||
('id_car_model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='inventory.carmodel', verbose_name='Model')),
|
||||
('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='organization_leads', to='inventory.organization')),
|
||||
('slug', models.SlugField(blank=True, help_text='Unique slug for the opportunity.', null=True, unique=True, verbose_name='Slug')),
|
||||
('loss_reason', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('car', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.car', verbose_name='Car')),
|
||||
('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='opportunities', to='inventory.customer')),
|
||||
('dealer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='opportunities', to='inventory.dealer')),
|
||||
('estimate', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='opportunity', to='django_ledger.estimatemodel')),
|
||||
('lead', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='opportunity', to='inventory.lead')),
|
||||
('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.organization', verbose_name='Organization')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Lead',
|
||||
'verbose_name_plural': 'Leads',
|
||||
'verbose_name': 'Opportunity',
|
||||
'verbose_name_plural': 'Opportunities',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='lead',
|
||||
name='organization',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='organization_leads', to='inventory.organization'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Refund',
|
||||
fields=[
|
||||
@ -609,12 +640,33 @@ class Migration(migrations.Migration):
|
||||
('payment_method', models.CharField(choices=[('cash', 'Cash'), ('finance', 'Finance'), ('lease', 'Lease'), ('credit_card', 'Credit Card'), ('bank_transfer', 'Bank Transfer'), ('sadad', 'SADAD')], max_length=20)),
|
||||
('comments', models.TextField(blank=True, null=True)),
|
||||
('formatted_order_id', models.CharField(editable=False, max_length=10, unique=True)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('agreed_price', models.DecimalField(decimal_places=2, help_text='The final agreed-upon selling price of the vehicle.', max_digits=12)),
|
||||
('down_payment_amount', models.DecimalField(decimal_places=2, default=0.0, help_text='The initial payment made by the customer.', max_digits=12)),
|
||||
('trade_in_value', models.DecimalField(decimal_places=2, default=0.0, help_text='The value of any vehicle traded in by the customer.', max_digits=12)),
|
||||
('loan_amount', models.DecimalField(decimal_places=2, default=0.0, help_text='The amount financed by a bank or third-party lender.', max_digits=12)),
|
||||
('total_paid_amount', models.DecimalField(decimal_places=2, default=0.0, help_text='Sum of down payment, trade-in value, and loan amount received so far.', max_digits=12)),
|
||||
('remaining_balance', models.DecimalField(decimal_places=2, default=0.0, help_text='The remaining amount due from the customer or financing.', max_digits=12)),
|
||||
('status', models.CharField(choices=[('PENDING_APPROVAL', 'Pending Approval'), ('APPROVED', 'Approved'), ('IN_FINANCING', 'In Financing'), ('PARTIALLY_PAID', 'Partially Paid'), ('FULLY_PAID', 'Fully Paid'), ('PENDING_DELIVERY', 'Pending Delivery'), ('DELIVERED', 'Delivered'), ('CANCELLED', 'Cancelled')], default='PENDING_APPROVAL', help_text='Current status of the sales order.', max_length=20)),
|
||||
('order_date', models.DateTimeField(default=django.utils.timezone.now, help_text='The date and time the sales order was created.')),
|
||||
('expected_delivery_date', models.DateField(blank=True, help_text='The planned date for vehicle delivery.', null=True)),
|
||||
('actual_delivery_date', models.DateTimeField(blank=True, help_text='The actual date and time the vehicle was delivered.', null=True)),
|
||||
('cancelled_date', models.DateTimeField(blank=True, help_text='The date and time the order was cancelled, if applicable.', null=True)),
|
||||
('cancellation_reason', models.TextField(blank=True, help_text='Reason for cancellation, if applicable.', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('car', models.ForeignKey(blank=True, help_text='The specific vehicle (VIN) being sold.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sales_orders', to='inventory.car')),
|
||||
('created_by', models.ForeignKey(help_text='The user who created this sales order.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_sales_orders', to=settings.AUTH_USER_MODEL)),
|
||||
('customer', models.ForeignKey(help_text='The customer making the purchase.', on_delete=django.db.models.deletion.PROTECT, related_name='sales_orders', to='inventory.customer')),
|
||||
('estimate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sale_orders', to='django_ledger.estimatemodel', verbose_name='Estimate')),
|
||||
('invoice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sale_orders', to='django_ledger.invoicemodel', verbose_name='Invoice')),
|
||||
('last_modified_by', models.ForeignKey(help_text='The user who last modified this sales order.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_sales_orders', to=settings.AUTH_USER_MODEL)),
|
||||
('opportunity', models.OneToOneField(help_text='The associated sales opportunity for this order.', on_delete=django.db.models.deletion.CASCADE, related_name='sales_order', to='inventory.opportunity')),
|
||||
('trade_in_vehicle', models.ForeignKey(blank=True, help_text='The vehicle traded in by the customer, if any.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='traded_in_on_orders', to='inventory.car')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created'],
|
||||
'verbose_name': 'Sales Order',
|
||||
'verbose_name_plural': 'Sales Orders',
|
||||
'ordering': ['-order_date'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@ -662,35 +714,17 @@ class Migration(migrations.Migration):
|
||||
('objects', inventory.models.StaffUserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Opportunity',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('stage', models.CharField(choices=[('discovery', 'Discovery'), ('proposal', 'Proposal'), ('negotiation', 'Negotiation'), ('closed_won', 'Closed Won'), ('closed_lost', 'Closed Lost')], max_length=20, verbose_name='Stage')),
|
||||
('probability', models.PositiveIntegerField(validators=[inventory.models.validate_probability])),
|
||||
('expected_revenue', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Expected Revenue')),
|
||||
('closing_date', models.DateField(blank=True, null=True, verbose_name='Closing Date')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
|
||||
('updated', models.DateTimeField(auto_now=True, verbose_name='Updated')),
|
||||
('slug', models.SlugField(blank=True, help_text='Unique slug for the opportunity.', null=True, unique=True, verbose_name='Slug')),
|
||||
('car', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.car', verbose_name='Car')),
|
||||
('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='opportunities', to='inventory.customer')),
|
||||
('dealer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='opportunities', to='inventory.dealer')),
|
||||
('estimate', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='opportunity', to='django_ledger.estimatemodel')),
|
||||
('lead', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='opportunity', to='inventory.lead')),
|
||||
('staff', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owner', to='inventory.staff', verbose_name='Owner')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Opportunity',
|
||||
'verbose_name_plural': 'Opportunities',
|
||||
},
|
||||
migrations.AddField(
|
||||
model_name='opportunity',
|
||||
name='staff',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owner', to='inventory.staff', verbose_name='Owner'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LeadStatusHistory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('old_status', models.CharField(choices=[('new', 'New'), ('follow_up', 'Follow-up'), ('negotiation', 'Negotiation'), ('won', 'Won'), ('lost', 'Lost'), ('closed', 'Closed')], max_length=50, verbose_name='Old Status')),
|
||||
('new_status', models.CharField(choices=[('new', 'New'), ('follow_up', 'Follow-up'), ('negotiation', 'Negotiation'), ('won', 'Won'), ('lost', 'Lost'), ('closed', 'Closed')], max_length=50, verbose_name='New Status')),
|
||||
('old_status', models.CharField(choices=[('new', 'New'), ('contacted', 'Contacted'), ('qualified', 'Qualified'), ('unqualified', 'Unqualified'), ('converted', 'Converted')], max_length=50, verbose_name='Old Status')),
|
||||
('new_status', models.CharField(choices=[('new', 'New'), ('contacted', 'Contacted'), ('qualified', 'Qualified'), ('unqualified', 'Unqualified'), ('converted', 'Converted')], max_length=50, verbose_name='New Status')),
|
||||
('changed_at', models.DateTimeField(auto_now_add=True, verbose_name='Changed At')),
|
||||
('lead', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='status_history', to='inventory.lead')),
|
||||
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='status_changes', to='inventory.staff')),
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-05-25 14:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('inventory', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='carfinance',
|
||||
name='is_sold',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
1048
inventory/models.py
1048
inventory/models.py
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,7 @@ Services module
|
||||
|
||||
import requests
|
||||
import json
|
||||
|
||||
from requests import Session
|
||||
|
||||
from pyvin import VIN
|
||||
from django.conf import settings
|
||||
@ -158,3 +158,6 @@ def elm(vin):
|
||||
}
|
||||
print([x for x in data.values()])
|
||||
return data if all([x for x in data.values()]) else None
|
||||
|
||||
|
||||
|
||||
|
||||
@ -56,6 +56,11 @@ User = get_user_model()
|
||||
|
||||
|
||||
# check with marwan
|
||||
@receiver(post_save, sender=models.Car)
|
||||
def create_dealers_make(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
models.DealersMake.objects.get_or_create(dealer=instance.dealer, car_make=instance.id_car_make)
|
||||
|
||||
@receiver(post_save, sender=models.Car)
|
||||
def create_car_location(sender, instance, created, **kwargs):
|
||||
"""
|
||||
|
||||
@ -25,6 +25,750 @@ def create_settings(pk):
|
||||
|
||||
@background
|
||||
def create_coa_accounts(pk):
|
||||
with transaction.atomic():
|
||||
instance = Dealer.objects.select_for_update().get(pk=pk)
|
||||
entity = instance.entity
|
||||
coa = entity.get_default_coa()
|
||||
|
||||
# accounts_data = [
|
||||
# # Current Assets (must start with 1)
|
||||
# {
|
||||
# 'code': '1010',
|
||||
# 'name': 'Cash on Hand',
|
||||
# 'role': roles.ASSET_CA_CASH,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': True,
|
||||
# },
|
||||
# {
|
||||
# 'code': '1020',
|
||||
# 'name': 'Bank',
|
||||
# 'role': roles.ASSET_CA_CASH,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': True,
|
||||
# },
|
||||
# {
|
||||
# 'code': '1030',
|
||||
# 'name': 'Accounts Receivable',
|
||||
# 'role': roles.ASSET_CA_RECEIVABLES,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': True
|
||||
# },
|
||||
# {
|
||||
# 'code': '1040',
|
||||
# 'name': 'Inventory (Cars)',
|
||||
# 'role': roles.ASSET_CA_INVENTORY,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': True
|
||||
# },
|
||||
# {
|
||||
# 'code': '1045',
|
||||
# 'name': 'Spare Parts Inventory',
|
||||
# 'role': roles.ASSET_CA_INVENTORY,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '1050',
|
||||
# 'name': 'Employee Advances',
|
||||
# 'role': roles.ASSET_CA_RECEIVABLES,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '1060',
|
||||
# 'name': 'Prepaid Expenses',
|
||||
# 'role': roles.ASSET_CA_PREPAID,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '1070',
|
||||
# 'name': 'Notes Receivable',
|
||||
# 'role': roles.ASSET_LTI_NOTES_RECEIVABLE,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
|
||||
# # Fixed Assets (must also start with 1)
|
||||
# {
|
||||
# 'code': '1110',
|
||||
# 'name': 'Lands',
|
||||
# 'role': roles.ASSET_LTI_LAND,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '1111',
|
||||
# 'name': 'Buildings',
|
||||
# 'role': roles.ASSET_PPE_BUILDINGS,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '1112',
|
||||
# 'name': 'Company Vehicles',
|
||||
# 'role': roles.ASSET_PPE_EQUIPMENT,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '1113',
|
||||
# 'name': 'Equipment & Tools',
|
||||
# 'role': roles.ASSET_PPE_EQUIPMENT,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '1114',
|
||||
# 'name': 'Furniture & Fixtures',
|
||||
# 'role': roles.ASSET_PPE_EQUIPMENT,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '1115',
|
||||
# 'name': 'Other Fixed Assets',
|
||||
# 'role': roles.ASSET_PPE_EQUIPMENT,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '1120',
|
||||
# 'name': 'Long-term Investments',
|
||||
# 'role': roles.ASSET_LTI_SECURITIES,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '1130',
|
||||
# 'name': 'Intangible Assets',
|
||||
# 'role': roles.ASSET_INTANGIBLE_ASSETS,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
|
||||
# # Current Liabilities (must start with 2)
|
||||
# {
|
||||
# 'code': '2010',
|
||||
# 'name': 'Accounts Payable',
|
||||
# 'role': roles.LIABILITY_CL_ACC_PAYABLE,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': True
|
||||
# },
|
||||
# {
|
||||
# 'code': '2020',
|
||||
# 'name': 'Notes Payable',
|
||||
# 'role': roles.LIABILITY_CL_ST_NOTES_PAYABLE,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '2030',
|
||||
# 'name': 'Short-term Loans',
|
||||
# 'role': roles.LIABILITY_CL_ST_NOTES_PAYABLE,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '2040',
|
||||
# 'name': 'Employee Payables',
|
||||
# 'role': roles.LIABILITY_CL_WAGES_PAYABLE,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '2050',
|
||||
# 'name': 'Accrued Expenses',
|
||||
# 'role': roles.LIABILITY_CL_OTHER,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '2060',
|
||||
# 'name': 'Accrued Taxes',
|
||||
# 'role': roles.LIABILITY_CL_TAXES_PAYABLE,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '2070',
|
||||
# 'name': 'Provisions',
|
||||
# 'role': roles.LIABILITY_CL_OTHER,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
|
||||
# # Long-term Liabilities (must also start with 2)
|
||||
# {
|
||||
# 'code': '2210',
|
||||
# 'name': 'Long-term Bank Loans',
|
||||
# 'role': roles.LIABILITY_LTL_NOTES_PAYABLE,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '2220',
|
||||
# 'name': 'Lease Liabilities',
|
||||
# 'role': roles.LIABILITY_LTL_NOTES_PAYABLE,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '2230',
|
||||
# 'name': 'Other Long-term Liabilities',
|
||||
# 'role': roles.LIABILITY_LTL_NOTES_PAYABLE,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
|
||||
# # Equity (must start with 3)
|
||||
# {
|
||||
# 'code': '3010',
|
||||
# 'name': 'Capital',
|
||||
# 'role': roles.EQUITY_CAPITAL,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': True
|
||||
# },
|
||||
# {
|
||||
# 'code': '3020',
|
||||
# 'name': 'Statutory Reserve',
|
||||
# 'role': roles.EQUITY_ADJUSTMENT,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '3030',
|
||||
# 'name': 'Retained Earnings',
|
||||
# 'role': roles.EQUITY_ADJUSTMENT,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '3040',
|
||||
# 'name': 'Profit & Loss for the Period',
|
||||
# 'role': roles.EQUITY_ADJUSTMENT,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
|
||||
# # Revenue (must start with 4)
|
||||
# {
|
||||
# 'code': '4010',
|
||||
# 'name': 'Car Sales',
|
||||
# 'role': roles.INCOME_OPERATIONAL,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': True
|
||||
# },
|
||||
# {
|
||||
# 'code': '4020',
|
||||
# 'name': 'After-Sales Services',
|
||||
# 'role': roles.INCOME_OPERATIONAL,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '4030',
|
||||
# 'name': 'Car Rental Income',
|
||||
# 'role': roles.INCOME_PASSIVE,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '4040',
|
||||
# 'name': 'Other Income',
|
||||
# 'role': roles.INCOME_OTHER,
|
||||
# 'balance_type': roles.CREDIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
|
||||
# # Expenses (must start with 5 for COGS, 6 for others)
|
||||
# {
|
||||
# 'code': '5010',
|
||||
# 'name': 'Cost of Goods Sold',
|
||||
# 'role': roles.COGS,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': True
|
||||
# },
|
||||
# {
|
||||
# 'code': '5015',
|
||||
# 'name': 'Spare Parts Cost Consumed',
|
||||
# 'role': roles.COGS,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '6010',
|
||||
# 'name': 'Salaries & Wages',
|
||||
# 'role': roles.EXPENSE_OPERATIONAL,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '6020',
|
||||
# 'name': 'Rent',
|
||||
# 'role': roles.EXPENSE_OPERATIONAL,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '6030',
|
||||
# 'name': 'Utilities',
|
||||
# 'role': roles.EXPENSE_OPERATIONAL,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '6040',
|
||||
# 'name': 'Advertising & Marketing',
|
||||
# 'role': roles.EXPENSE_OPERATIONAL,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '6050',
|
||||
# 'name': 'Maintenance',
|
||||
# 'role': roles.EXPENSE_OPERATIONAL,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '6060',
|
||||
# 'name': 'Operating Expenses',
|
||||
# 'role': roles.EXPENSE_OPERATIONAL,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '6070',
|
||||
# 'name': 'Depreciation',
|
||||
# 'role': roles.EXPENSE_DEPRECIATION,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '6080',
|
||||
# 'name': 'Fees & Taxes',
|
||||
# 'role': roles.EXPENSE_OPERATIONAL,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '6090',
|
||||
# 'name': 'Bank Charges',
|
||||
# 'role': roles.EXPENSE_OPERATIONAL,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# },
|
||||
# {
|
||||
# 'code': '6100',
|
||||
# 'name': 'Other Expenses',
|
||||
# 'role': roles.EXPENSE_OTHER,
|
||||
# 'balance_type': roles.DEBIT,
|
||||
# 'locked': False
|
||||
# }
|
||||
# ]
|
||||
|
||||
accounts_data = [
|
||||
# Current Assets (must start with 1)
|
||||
{
|
||||
'code': '1010',
|
||||
'name': 'Cash on Hand',
|
||||
'role': roles.ASSET_CA_CASH,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': True,
|
||||
'default': True # Default for ASSET_CA_CASH
|
||||
},
|
||||
{
|
||||
'code': '1020',
|
||||
'name': 'Bank',
|
||||
'role': roles.ASSET_CA_CASH,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': True,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '1030',
|
||||
'name': 'Accounts Receivable',
|
||||
'role': roles.ASSET_CA_RECEIVABLES,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': True,
|
||||
'default': True # Default for ASSET_CA_RECEIVABLES
|
||||
},
|
||||
{
|
||||
'code': '1040',
|
||||
'name': 'Inventory (Cars)',
|
||||
'role': roles.ASSET_CA_INVENTORY,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': True,
|
||||
'default': True # Default for ASSET_CA_INVENTORY
|
||||
},
|
||||
{
|
||||
'code': '1045',
|
||||
'name': 'Spare Parts Inventory',
|
||||
'role': roles.ASSET_CA_INVENTORY,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '1050',
|
||||
'name': 'Employee Advances',
|
||||
'role': roles.ASSET_CA_RECEIVABLES,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '1060',
|
||||
'name': 'Prepaid Expenses',
|
||||
'role': roles.ASSET_CA_PREPAID,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': True # Default for ASSET_CA_PREPAID
|
||||
},
|
||||
{
|
||||
'code': '1070',
|
||||
'name': 'Notes Receivable',
|
||||
'role': roles.ASSET_LTI_NOTES_RECEIVABLE,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': True # Default for ASSET_LTI_NOTES_RECEIVABLE
|
||||
},
|
||||
|
||||
# Fixed Assets (must also start with 1)
|
||||
{
|
||||
'code': '1110',
|
||||
'name': 'Lands',
|
||||
'role': roles.ASSET_LTI_LAND,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': True # Default for ASSET_LTI_LAND
|
||||
},
|
||||
{
|
||||
'code': '1111',
|
||||
'name': 'Buildings',
|
||||
'role': roles.ASSET_PPE_BUILDINGS,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': True # Default for ASSET_PPE_BUILDINGS
|
||||
},
|
||||
{
|
||||
'code': '1112',
|
||||
'name': 'Company Vehicles',
|
||||
'role': roles.ASSET_PPE_EQUIPMENT,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': True # Default for ASSET_PPE_EQUIPMENT
|
||||
},
|
||||
{
|
||||
'code': '1113',
|
||||
'name': 'Equipment & Tools',
|
||||
'role': roles.ASSET_PPE_EQUIPMENT,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '1114',
|
||||
'name': 'Furniture & Fixtures',
|
||||
'role': roles.ASSET_PPE_EQUIPMENT,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '1115',
|
||||
'name': 'Other Fixed Assets',
|
||||
'role': roles.ASSET_PPE_EQUIPMENT,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '1120',
|
||||
'name': 'Long-term Investments',
|
||||
'role': roles.ASSET_LTI_SECURITIES,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': True # Default for ASSET_LTI_SECURITIES
|
||||
},
|
||||
{
|
||||
'code': '1130',
|
||||
'name': 'Intangible Assets',
|
||||
'role': roles.ASSET_INTANGIBLE_ASSETS,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': True # Default for ASSET_INTANGIBLE_ASSETS
|
||||
},
|
||||
|
||||
# Current Liabilities (must start with 2)
|
||||
{
|
||||
'code': '2010',
|
||||
'name': 'Accounts Payable',
|
||||
'role': roles.LIABILITY_CL_ACC_PAYABLE,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': True,
|
||||
'default': True # Default for LIABILITY_CL_ACC_PAYABLE
|
||||
},
|
||||
{
|
||||
'code': '2020',
|
||||
'name': 'Notes Payable',
|
||||
'role': roles.LIABILITY_CL_ST_NOTES_PAYABLE,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': False,
|
||||
'default': True # Default for LIABILITY_CL_ST_NOTES_PAYABLE
|
||||
},
|
||||
{
|
||||
'code': '2030',
|
||||
'name': 'Short-term Loans',
|
||||
'role': roles.LIABILITY_CL_ST_NOTES_PAYABLE,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '2040',
|
||||
'name': 'Employee Payables',
|
||||
'role': roles.LIABILITY_CL_WAGES_PAYABLE,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': False,
|
||||
'default': True # Default for LIABILITY_CL_WAGES_PAYABLE
|
||||
},
|
||||
{
|
||||
'code': '2050',
|
||||
'name': 'Accrued Expenses',
|
||||
'role': roles.LIABILITY_CL_OTHER,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': False,
|
||||
'default': True # Default for LIABILITY_CL_OTHER
|
||||
},
|
||||
{
|
||||
'code': '2060',
|
||||
'name': 'Accrued Taxes',
|
||||
'role': roles.LIABILITY_CL_TAXES_PAYABLE,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': False,
|
||||
'default': True # Default for LIABILITY_CL_TAXES_PAYABLE
|
||||
},
|
||||
{
|
||||
'code': '2070',
|
||||
'name': 'Provisions',
|
||||
'role': roles.LIABILITY_CL_OTHER,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
|
||||
# Long-term Liabilities (must also start with 2)
|
||||
{
|
||||
'code': '2210',
|
||||
'name': 'Long-term Bank Loans',
|
||||
'role': roles.LIABILITY_LTL_NOTES_PAYABLE,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': False,
|
||||
'default': True # Default for LIABILITY_LTL_NOTES_PAYABLE
|
||||
},
|
||||
{
|
||||
'code': '2220',
|
||||
'name': 'Lease Liabilities',
|
||||
'role': roles.LIABILITY_LTL_NOTES_PAYABLE,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '2230',
|
||||
'name': 'Other Long-term Liabilities',
|
||||
'role': roles.LIABILITY_LTL_NOTES_PAYABLE,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
|
||||
# Equity (must start with 3)
|
||||
{
|
||||
'code': '3010',
|
||||
'name': 'Capital',
|
||||
'role': roles.EQUITY_CAPITAL,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': True,
|
||||
'default': True # Default for EQUITY_CAPITAL
|
||||
},
|
||||
{
|
||||
'code': '3020',
|
||||
'name': 'Statutory Reserve',
|
||||
'role': roles.EQUITY_ADJUSTMENT,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': False,
|
||||
'default': True # Default for EQUITY_ADJUSTMENT
|
||||
},
|
||||
{
|
||||
'code': '3030',
|
||||
'name': 'Retained Earnings',
|
||||
'role': roles.EQUITY_ADJUSTMENT,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '3040',
|
||||
'name': 'Profit & Loss for the Period',
|
||||
'role': roles.EQUITY_ADJUSTMENT,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
|
||||
# Revenue (must start with 4)
|
||||
{
|
||||
'code': '4010',
|
||||
'name': 'Car Sales',
|
||||
'role': roles.INCOME_OPERATIONAL,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': True,
|
||||
'default': True # Default for INCOME_OPERATIONAL
|
||||
},
|
||||
{
|
||||
'code': '4020',
|
||||
'name': 'After-Sales Services',
|
||||
'role': roles.INCOME_OPERATIONAL,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '4030',
|
||||
'name': 'Car Rental Income',
|
||||
'role': roles.INCOME_PASSIVE,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': False,
|
||||
'default': True # Default for INCOME_PASSIVE
|
||||
},
|
||||
{
|
||||
'code': '4040',
|
||||
'name': 'Other Income',
|
||||
'role': roles.INCOME_OTHER,
|
||||
'balance_type': roles.CREDIT,
|
||||
'locked': False,
|
||||
'default': True # Default for INCOME_OTHER
|
||||
},
|
||||
|
||||
# Expenses (must start with 5 for COGS, 6 for others)
|
||||
{
|
||||
'code': '5010',
|
||||
'name': 'Cost of Goods Sold',
|
||||
'role': roles.COGS,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': True,
|
||||
'default': True # Default for COGS
|
||||
},
|
||||
{
|
||||
'code': '5015',
|
||||
'name': 'Spare Parts Cost Consumed',
|
||||
'role': roles.COGS,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '6010',
|
||||
'name': 'Salaries & Wages',
|
||||
'role': roles.EXPENSE_OPERATIONAL,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': True # Default for EXPENSE_OPERATIONAL
|
||||
},
|
||||
{
|
||||
'code': '6020',
|
||||
'name': 'Rent',
|
||||
'role': roles.EXPENSE_OPERATIONAL,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '6030',
|
||||
'name': 'Utilities',
|
||||
'role': roles.EXPENSE_OPERATIONAL,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '6040',
|
||||
'name': 'Advertising & Marketing',
|
||||
'role': roles.EXPENSE_OPERATIONAL,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '6050',
|
||||
'name': 'Maintenance',
|
||||
'role': roles.EXPENSE_OPERATIONAL,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '6060',
|
||||
'name': 'Operating Expenses',
|
||||
'role': roles.EXPENSE_OPERATIONAL,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '6070',
|
||||
'name': 'Depreciation',
|
||||
'role': roles.EXPENSE_DEPRECIATION,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': True # Default for EXPENSE_DEPRECIATION
|
||||
},
|
||||
{
|
||||
'code': '6080',
|
||||
'name': 'Fees & Taxes',
|
||||
'role': roles.EXPENSE_OPERATIONAL,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '6090',
|
||||
'name': 'Bank Charges',
|
||||
'role': roles.EXPENSE_OPERATIONAL,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': False
|
||||
},
|
||||
{
|
||||
'code': '6100',
|
||||
'name': 'Other Expenses',
|
||||
'role': roles.EXPENSE_OTHER,
|
||||
'balance_type': roles.DEBIT,
|
||||
'locked': False,
|
||||
'default': True # Default for EXPENSE_OTHER
|
||||
}
|
||||
]
|
||||
|
||||
for account_data in accounts_data:
|
||||
try:
|
||||
account = entity.create_account(
|
||||
coa_model=coa,
|
||||
code=account_data['code'],
|
||||
name=_(account_data['name']),
|
||||
role=_(account_data['role']),
|
||||
balance_type=_(account_data['balance_type']),
|
||||
active=True
|
||||
)
|
||||
account.role_default = account_data['default']
|
||||
account.save()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
@background
|
||||
def create_coa_accounts1(pk):
|
||||
with transaction.atomic():
|
||||
instance = Dealer.objects.select_for_update().get(pk=pk)
|
||||
entity = instance.entity
|
||||
|
||||
@ -129,7 +129,7 @@ def period_navigation(context, base_url: str):
|
||||
def balance_sheet_statement(context, io_model, to_date=None):
|
||||
user_model = context['user']
|
||||
activity = context['request'].GET.get('activity')
|
||||
entity_slug = context['view'].kwargs.get('entity_slug')
|
||||
entity_slug = context['view'].kwargs.get('entity_slug')
|
||||
if not to_date:
|
||||
to_date = context['to_date']
|
||||
|
||||
@ -358,4 +358,9 @@ def splitlines(value):
|
||||
def currency_format(value):
|
||||
if not value:
|
||||
value = 0.00
|
||||
return number_format(value, decimal_pos=2, use_l10n=True, force_grouping=True)
|
||||
return number_format(value, decimal_pos=2, use_l10n=True, force_grouping=True)
|
||||
|
||||
|
||||
@register.filter
|
||||
def filter_by_role(accounts, role_prefix):
|
||||
return [account for account in accounts if account.role.startswith(role_prefix)]
|
||||
@ -281,6 +281,7 @@ urlpatterns = [
|
||||
path(
|
||||
"cars/<slug:slug>/add-color/", views.CarColorCreate.as_view(), name="add_color"
|
||||
),
|
||||
path('car/colors/<slug:slug>/update/', views.CarColorsUpdateView.as_view(), name='car_colors_update'),
|
||||
path(
|
||||
"cars/<slug:slug>/location/add/",
|
||||
views.CarLocationCreateView.as_view(),
|
||||
@ -578,7 +579,7 @@ path(
|
||||
name="estimate_detail",
|
||||
),
|
||||
path("sales/estimates/create/", views.create_estimate, name="estimate_create"),
|
||||
path("sales/estimates/create/<int:pk>/", views.create_estimate, name="estimate_create_from_opportunity"),
|
||||
path("sales/estimates/create/<slug:slug>/", views.create_estimate, name="estimate_create_from_opportunity"),
|
||||
path(
|
||||
"sales/estimates/<uuid:pk>/estimate_mark_as/",
|
||||
views.estimate_mark_as,
|
||||
@ -598,6 +599,7 @@ path(
|
||||
"sales/estimates/<uuid:pk>/send_email", views.send_email_view, name="send_email"
|
||||
),
|
||||
path('sales/estimates/<uuid:pk>/sale_order/', views.create_sale_order, name='create_sale_order'),
|
||||
path('sales/estimates/<uuid:pk>/sale_order/<int:order_pk>/details/', views.SaleOrderDetail.as_view(), name='sale_order_details'),
|
||||
path('sales/estimates/<uuid:pk>/sale_order/preview/', views.preview_sale_order, name='preview_sale_order'),
|
||||
|
||||
# Invoice
|
||||
@ -807,6 +809,9 @@ path(
|
||||
path('management/user_management/', views.user_management, name='user_management'),
|
||||
path('management/<str:content_type>/<slug:slug>/activate_account/', views.activate_account, name='activate_account'),
|
||||
path('management/<str:content_type>/<slug:slug>/permenant_delete_account/', views.permenant_delete_account, name='permenant_delete_account'),
|
||||
path('management/audit_log_dashboard/', views.AuditLogDashboardView.as_view(), name='audit_log_dashboard'),
|
||||
|
||||
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -27,6 +27,11 @@ from django.utils.translation import get_language
|
||||
from appointment.models import StaffMember
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
import secrets
|
||||
|
||||
def make_random_password(length=10, allowed_chars='abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'):
|
||||
return ''.join(secrets.choice(allowed_chars) for i in range(length))
|
||||
|
||||
def get_jwt_token():
|
||||
"""
|
||||
Fetches a JWT token from an external authentication API.
|
||||
@ -999,13 +1004,13 @@ class CarFinanceCalculator:
|
||||
car_finance = self._get_nested_value(item, self.CAR_FINANCE_KEY)
|
||||
car_info = self._get_nested_value(item, self.CAR_INFO_KEY)
|
||||
unit_price = Decimal(car_finance.get('selling_price', 0))
|
||||
|
||||
return {
|
||||
"item_number": item.item_model.item_number,
|
||||
"vin": car_info.get('vin'),
|
||||
"make": car_info.get('make'),
|
||||
"model": car_info.get('model'),
|
||||
"year": car_info.get('year'),
|
||||
"logo": item.item_model.car.id_car_make.logo.url,
|
||||
"trim": car_info.get('trim'),
|
||||
"mileage": car_info.get('mileage'),
|
||||
"cost_price": car_finance.get('cost_price'),
|
||||
@ -1028,8 +1033,7 @@ class CarFinanceCalculator:
|
||||
|
||||
def calculate_totals(self):
|
||||
total_price = sum(
|
||||
Decimal(self._get_nested_value(item, self.CAR_FINANCE_KEY, 'selling_price')) *
|
||||
int(self._get_quantity(item))
|
||||
Decimal(self._get_nested_value(item, self.CAR_FINANCE_KEY, 'selling_price')) * int(self._get_quantity(item))
|
||||
for item in self.item_transactions
|
||||
)
|
||||
total_additionals = sum(Decimal(x.get('price_')) for x in self._get_additional_services())
|
||||
@ -1068,7 +1072,6 @@ class CarFinanceCalculator:
|
||||
|
||||
|
||||
|
||||
|
||||
def get_item_transactions(txs):
|
||||
"""
|
||||
Extracts and compiles relevant transaction details from a list of transactions,
|
||||
@ -1196,7 +1199,7 @@ def handle_account_process(invoice,amount,finance_data):
|
||||
description=f"Payment for Invoice {invoice.invoice_number}",
|
||||
ledger=invoice.ledger,
|
||||
locked=False,
|
||||
origin=f"Sale of {car.name}{car.vin}: Invoice {invoice.invoice_number}",
|
||||
origin=f"Sale of {car.id_car_make.name}{car.vin}: Invoice {invoice.invoice_number}",
|
||||
)
|
||||
|
||||
TransactionModel.objects.create(
|
||||
@ -1217,7 +1220,7 @@ def handle_account_process(invoice,amount,finance_data):
|
||||
|
||||
journal_cogs = JournalEntryModel.objects.create(
|
||||
posted=False,
|
||||
description=f"COGS of {car.name}{car.vin}: Invoice {invoice.invoice_number}",
|
||||
description=f"COGS of {car.id_car_make.name}{car.vin}: Invoice {invoice.invoice_number}",
|
||||
ledger=invoice.ledger,
|
||||
locked=False,
|
||||
origin="Payment",
|
||||
@ -1237,8 +1240,10 @@ def handle_account_process(invoice,amount,finance_data):
|
||||
tx_type="credit",
|
||||
description="",
|
||||
)
|
||||
|
||||
car.item_model.for_inventory = False
|
||||
try:
|
||||
car.item_model.for_inventory = False
|
||||
except Exception as e:
|
||||
print(e)
|
||||
car.finances.is_sold = True
|
||||
car.finances.save()
|
||||
car.item_model.save()
|
||||
|
||||
@ -16,7 +16,7 @@ from urllib.parse import urlparse, urlunparse
|
||||
|
||||
#####################################################################
|
||||
from inventory.models import Status as LeadStatus
|
||||
|
||||
from django.db import IntegrityError
|
||||
from background_task.models import Task
|
||||
from django.db.models.deletion import RestrictedError
|
||||
from django.http.response import StreamingHttpResponse
|
||||
@ -26,7 +26,7 @@ from django.db.models import Q
|
||||
from django.conf import settings
|
||||
from django.db.models import Func
|
||||
from django.contrib import messages
|
||||
from django.http import Http404, JsonResponse, HttpResponseForbidden
|
||||
from django.http import Http404, HttpResponseRedirect, JsonResponse, HttpResponseForbidden
|
||||
from django.forms import HiddenInput, ValidationError
|
||||
from django.shortcuts import HttpResponse
|
||||
|
||||
@ -60,6 +60,7 @@ from django.views.generic import (
|
||||
ArchiveIndexView,
|
||||
)
|
||||
|
||||
|
||||
# Django Ledger
|
||||
from django_ledger.io import roles
|
||||
from django_ledger.utils import accruable_net_summary
|
||||
@ -150,6 +151,10 @@ from .utils import (
|
||||
)
|
||||
from .tasks import create_accounts_for_make, send_email
|
||||
|
||||
#djago easy audit log
|
||||
from easyaudit.models import RequestEvent, CRUDEvent, LoginEvent
|
||||
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@ -658,40 +663,40 @@ class AjaxHandlerView(LoginRequiredMixin, View):
|
||||
vin_no = vin_no.strip()
|
||||
vin_data = {}
|
||||
decoding_method = ""
|
||||
if result := decodevin(vin_no):
|
||||
manufacturer_name, model_name, year_model = result.values()
|
||||
car_make = get_make(manufacturer_name)
|
||||
car_model = get_model(model_name, car_make)
|
||||
|
||||
logger.info(
|
||||
f"VIN decoded using {decoding_method}: Make={manufacturer_name}, Model={model_name}, Year={year_model}"
|
||||
)
|
||||
|
||||
if not car_make:
|
||||
return JsonResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": _("Manufacturer not found in the database"),
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
vin_data["make_id"] = car_make.id_car_make
|
||||
vin_data["name"] = car_make.name
|
||||
vin_data["arabic_name"] = car_make.arabic_name
|
||||
|
||||
if not car_model:
|
||||
vin_data["model_id"] = ""
|
||||
else:
|
||||
vin_data["model_id"] = car_model.id_car_model
|
||||
vin_data["year"] = year_model
|
||||
return JsonResponse({"success": True, "data": vin_data})
|
||||
|
||||
# manufacturer_name = model_name = year_model = None
|
||||
if not (result := decodevin(vin_no)):
|
||||
return JsonResponse(
|
||||
{"success": False, "error": _("VIN not found in all sources")},
|
||||
status=404,
|
||||
)
|
||||
|
||||
manufacturer_name, model_name, year_model = result.values()
|
||||
car_make = get_make(manufacturer_name)
|
||||
car_model = get_model(model_name, car_make)
|
||||
|
||||
logger.info(
|
||||
f"VIN decoded using {decoding_method}: Make={manufacturer_name}, Model={model_name}, Year={year_model}"
|
||||
return JsonResponse(
|
||||
{"success": False, "error": _("VIN not found in all sources")},
|
||||
status=404,
|
||||
)
|
||||
|
||||
if not car_make:
|
||||
return JsonResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": _("Manufacturer not found in the database"),
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
vin_data["make_id"] = car_make.id_car_make
|
||||
vin_data["name"] = car_make.name
|
||||
vin_data["arabic_name"] = car_make.arabic_name
|
||||
|
||||
if not car_model:
|
||||
vin_data["model_id"] = ""
|
||||
else:
|
||||
vin_data["model_id"] = car_model.id_car_model
|
||||
vin_data["year"] = year_model
|
||||
return JsonResponse({"success": True, "data": vin_data})
|
||||
|
||||
def get_models(self, request):
|
||||
make_id = request.GET.get("make_id")
|
||||
car_models = (
|
||||
@ -945,37 +950,50 @@ class CarColorCreate(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
|
||||
context["car"] = get_object_or_404(models.Car, slug=self.kwargs["slug"])
|
||||
return context
|
||||
|
||||
|
||||
|
||||
class CarColorsUpdateView( LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
model = models.CarColors
|
||||
form_class = forms.CarColorsForm
|
||||
template_name = "inventory/add_colors.html"
|
||||
success_message = _("Car finance details updated successfully")
|
||||
permission_required = ["inventory.change_carfinance"]
|
||||
success_message = _("Car Colors details updated successfully")
|
||||
permission_required = ["inventory.change_car"]
|
||||
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
"""
|
||||
Retrieves the CarColors instance associated with the Car slug from the URL.
|
||||
This ensures we are updating the colors for the correct car.
|
||||
"""
|
||||
|
||||
# Get the car_slug from the URL keywords arguments
|
||||
slug = self.kwargs.get('slug')
|
||||
|
||||
# If no car_slug is provided, it's an invalid request
|
||||
if not slug:
|
||||
# You might want to raise Http404 or a more specific error here
|
||||
raise ValueError("Car slug is required to identify the colors to update.")
|
||||
|
||||
|
||||
return get_object_or_404(models.CarColors, car__slug=slug)
|
||||
|
||||
def get_success_url(self):
|
||||
"""
|
||||
Redirects to the car's detail page using its slug after a successful update.
|
||||
"""
|
||||
# self.object refers to the CarColors instance that was just updated.
|
||||
# self.object.car then refers to the associated Car instance.
|
||||
return reverse("car_detail", kwargs={"slug": self.object.car.slug})
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["instance"] = self.get_object()
|
||||
return kwargs
|
||||
|
||||
def get_initial(self):
|
||||
initial = super().get_initial()
|
||||
instance = self.get_object()
|
||||
dealer = get_user_type(self.request)
|
||||
selected_items = instance.additional_services.filter(dealer=dealer)
|
||||
initial["additional_finances"] = selected_items
|
||||
return initial
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class)
|
||||
dealer = get_user_type(self.request)
|
||||
form.fields[
|
||||
"additional_finances"
|
||||
].queryset = models.AdditionalServices.objects.filter(dealer=dealer)
|
||||
return form
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""
|
||||
Adds the related Car object to the template context.
|
||||
"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
# self.object is already available here from get_object()
|
||||
context['car'] = self.object.car
|
||||
context['page_title'] = _("Update Colors for %(car_name)s") % {'car_name': context['car']}
|
||||
return context
|
||||
|
||||
class CarListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
||||
"""
|
||||
@ -2691,12 +2709,18 @@ class UserCreateView(
|
||||
|
||||
email = form.cleaned_data["email"]
|
||||
password = "Tenhal@123"
|
||||
|
||||
user = User.objects.create_user(
|
||||
username=email, email=email, password=password
|
||||
)
|
||||
user.is_staff = True
|
||||
user.save()
|
||||
try:
|
||||
user = User.objects.create_user(
|
||||
username=email, email=email, password=password
|
||||
)
|
||||
user.is_staff = True
|
||||
user.save()
|
||||
except IntegrityError as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
_("A user with this email already exists. Please use a different email."),
|
||||
)
|
||||
return redirect("user_create")
|
||||
staff_member = StaffMember.objects.create(user=user)
|
||||
for service in form.cleaned_data["service_offered"]:
|
||||
staff_member.services_offered.add(service)
|
||||
@ -3592,7 +3616,7 @@ class EstimateListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
||||
# @csrf_exempt
|
||||
@login_required
|
||||
@permission_required("django_ledger.add_estimatemodel", raise_exception=True)
|
||||
def create_estimate(request, pk=None):
|
||||
def create_estimate(request, slug=None):
|
||||
"""
|
||||
Creates a new estimate based on the provided data and saves it. This function processes
|
||||
a POST request and expects a JSON payload containing details of the estimate such as
|
||||
@ -3780,7 +3804,7 @@ def create_estimate(request, pk=None):
|
||||
|
||||
opportunity_id = data.get("opportunity_id")
|
||||
if opportunity_id != "None":
|
||||
opportunity = models.Opportunity.objects.get(pk=int(opportunity_id))
|
||||
opportunity = models.Opportunity.objects.get(slug=opportunity_id)
|
||||
opportunity.estimate = estimate
|
||||
opportunity.save()
|
||||
|
||||
@ -3799,9 +3823,10 @@ def create_estimate(request, pk=None):
|
||||
active=True
|
||||
)
|
||||
|
||||
if pk:
|
||||
opportunity = models.Opportunity.objects.get(pk=pk)
|
||||
if slug:
|
||||
opportunity = models.Opportunity.objects.get(slug=slug)
|
||||
customer = opportunity.customer
|
||||
form.fields['customer'].queryset = models.Customer.objects.filter(pk=customer.pk)
|
||||
form.initial["customer"] = customer
|
||||
|
||||
car_list = (
|
||||
@ -3842,7 +3867,7 @@ def create_estimate(request, pk=None):
|
||||
}
|
||||
for x in car_list
|
||||
],
|
||||
"opportunity_id": pk if pk else None,
|
||||
"opportunity_id": slug if slug else None,
|
||||
"customer_count": entity.get_customers().count(),
|
||||
}
|
||||
|
||||
@ -3912,7 +3937,13 @@ def create_sale_order(request, pk):
|
||||
if request.method == "POST":
|
||||
form = forms.SaleOrderForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
instance = form.save(commit=False)
|
||||
instance.estimate = estimate
|
||||
instance.customer = estimate.customer.customer_set.first()
|
||||
instance.created_by = request.user
|
||||
instance.last_modified_by = request.user
|
||||
instance.save()
|
||||
|
||||
if not estimate.is_approved():
|
||||
estimate.mark_as_approved()
|
||||
estimate.save()
|
||||
@ -3925,8 +3956,11 @@ def create_sale_order(request, pk):
|
||||
dealer = get_user_type(request)
|
||||
|
||||
item.item_model.car.mark_as_sold()
|
||||
# models.Activity.objects.create(dealer=dealer,content_object=item.item_model.car, notes="Car Sold",created_by=request.user,activity_type=models.ActionChoices.SALE_CAR)
|
||||
|
||||
return redirect("estimate_detail", pk=estimate.pk)
|
||||
# models.Activity.objects.create(dealer=dealer,content_object=item.item_model.car, notes="Car Sold",created_by=request.user,activity_type=models.ActionChoices.SALE_CAR)
|
||||
else:
|
||||
print(form.errors)
|
||||
|
||||
messages.success(request, "Sale Order created successfully")
|
||||
return redirect("estimate_detail", pk=estimate.pk)
|
||||
@ -3938,10 +3972,30 @@ def create_sale_order(request, pk):
|
||||
finance_data = calculator.get_finance_data()
|
||||
return render(
|
||||
request,
|
||||
"sales/estimates/sale_order_form.html",
|
||||
"sales/estimates/sale_order_form1.html",
|
||||
{"form": form, "estimate": estimate, "items": items, "data": finance_data},
|
||||
)
|
||||
|
||||
class SaleOrderDetail(DetailView):
|
||||
model = models.SaleOrder
|
||||
template_name = "sales/orders/order_details.html"
|
||||
context_object_name = "saleorder"
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
order_pk = self.kwargs.get('order_pk')
|
||||
return models.SaleOrder.objects.get(
|
||||
pk=order_pk,
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
saleorder = kwargs.get("object")
|
||||
estimate = saleorder.estimate
|
||||
if estimate.get_itemtxs_data():
|
||||
calculator = CarFinanceCalculator(estimate)
|
||||
finance_data = calculator.get_finance_data()
|
||||
kwargs["data"] = finance_data
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
@login_required
|
||||
def preview_sale_order(request, pk):
|
||||
@ -4408,9 +4462,12 @@ def invoice_create(request, pk):
|
||||
commit=True,
|
||||
operation=InvoiceModel.ITEMIZE_APPEND,
|
||||
)
|
||||
sale_order = estimate.sale_orders.first()
|
||||
sale_order.invoice = invoice
|
||||
invoice.bind_estimate(estimate)
|
||||
invoice.mark_as_review()
|
||||
estimate.mark_as_completed()
|
||||
sale_order.save()
|
||||
estimate.save()
|
||||
invoice.save()
|
||||
messages.success(request, "Invoice created successfully")
|
||||
@ -4730,7 +4787,7 @@ class LeadListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
||||
def get_queryset(self):
|
||||
query = self.request.GET.get("q")
|
||||
dealer = get_user_type(self.request)
|
||||
qs = models.Lead.objects.filter(dealer=dealer)
|
||||
qs = models.Lead.objects.filter(dealer=dealer).exclude(status="converted")
|
||||
if query:
|
||||
qs = apply_search_filters(qs, query)
|
||||
if self.request.is_dealer:
|
||||
@ -4784,6 +4841,7 @@ class LeadDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
|
||||
lead=self.object
|
||||
)
|
||||
context["transfer_form"] = forms.LeadTransferForm()
|
||||
context["transfer_form"].fields["transfer_to"].queryset = (models.Staff.objects.filter(dealer=self.object.dealer,staff_type="sales"))
|
||||
context["activity_form"] = forms.ActivityForm()
|
||||
context["staff_task_form"] = forms.StaffTaskForm()
|
||||
context["note_form"] = forms.NoteForm()
|
||||
@ -4834,9 +4892,10 @@ def lead_create(request):
|
||||
email=instance.email,
|
||||
first_name=instance.first_name,
|
||||
last_name=instance.last_name,
|
||||
active=False,
|
||||
)
|
||||
customer.create_user_model()
|
||||
customer.create_customer_model()
|
||||
customer.create_user_model(for_lead=True)
|
||||
customer.create_customer_model(for_lead=True)
|
||||
customer.save()
|
||||
instance.customer = customer
|
||||
|
||||
@ -4851,12 +4910,13 @@ def lead_create(request):
|
||||
phone_number=instance.phone_number,
|
||||
email=instance.email,
|
||||
name=instance.first_name + " " + instance.last_name,
|
||||
active=False,
|
||||
)
|
||||
organization.create_user_model()
|
||||
organization.create_customer_model()
|
||||
organization.create_user_model(for_lead=True)
|
||||
organization.create_customer_model(for_lead=True)
|
||||
organization.save()
|
||||
instance.organization = organization
|
||||
instance.next_action = LeadStatus.FOLLOW_UP
|
||||
instance.next_action = LeadStatus.NEW
|
||||
instance.save()
|
||||
messages.success(request, _("Lead created successfully"))
|
||||
return redirect("lead_list")
|
||||
@ -4864,7 +4924,13 @@ def lead_create(request):
|
||||
messages.error(
|
||||
request, f"Lead was not created ... : {str(form.errors)}"
|
||||
)
|
||||
print(form.errors)
|
||||
# if email:= request.POST.get("email"):
|
||||
# if models.Customer.objects.filter(email=email).exists():
|
||||
# form.errors['email'] = form.error_class([
|
||||
# _("Email already exists")
|
||||
# ])
|
||||
return render(request, "crm/leads/lead_form.html", {"form": form})
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Lead was not created ... : {str(e)}")
|
||||
|
||||
@ -4879,13 +4945,18 @@ def lead_create(request):
|
||||
else:
|
||||
dealer_make_list = models.DealersMake.objects.filter(dealer=dealer).values_list("car_make",flat=True)
|
||||
qs = form.fields["id_car_make"].queryset.filter(is_sa_import=True,pk__in=dealer_make_list)
|
||||
|
||||
form.fields["staff"].queryset = form.fields["staff"].queryset.filter(dealer=dealer,staff_type="sales")
|
||||
print(form.fields["staff"].queryset)
|
||||
if hasattr(request.user.staffmember,"staff"):
|
||||
form.initial["staff"] = request.user.staffmember.staff
|
||||
form.fields["staff"].widget.attrs["disabled"] = True
|
||||
form.fields["id_car_make"].queryset = qs
|
||||
form.fields["id_car_make"].choices = [
|
||||
(obj.id_car_make, obj.get_local_name()) for obj in qs
|
||||
]
|
||||
if first_make := qs.first():
|
||||
form.fields["id_car_model"].queryset = first_make.carmodel_set.all()
|
||||
|
||||
return render(request, "crm/leads/lead_form.html", {"form": form})
|
||||
|
||||
|
||||
@ -4915,7 +4986,7 @@ def update_lead_actions(request):
|
||||
current_action = request.POST.get("current_action")
|
||||
next_action = request.POST.get("next_action")
|
||||
next_action_date = request.POST.get("next_action_date", None)
|
||||
print(request.POST)
|
||||
|
||||
if not all([lead_id, current_action, next_action]):
|
||||
return JsonResponse(
|
||||
{"success": False, "message": "All fields are required"}, status=400
|
||||
@ -5065,12 +5136,14 @@ def add_note_to_opportunity(request, slug):
|
||||
:return: A redirect response to the detailed view of the opportunity.
|
||||
"""
|
||||
opportunity = get_object_or_404(models.Opportunity, slug=slug)
|
||||
dealer = get_user_type(request)
|
||||
if request.method == "POST":
|
||||
notes = request.POST.get("notes")
|
||||
if not notes:
|
||||
messages.error(request, _("Notes field is required"))
|
||||
else:
|
||||
models.Notes.objects.create(
|
||||
dealer=dealer,
|
||||
content_object=opportunity, created_by=request.user, note=notes
|
||||
)
|
||||
messages.success(request, _("Note added successfully"))
|
||||
@ -5172,13 +5245,7 @@ def schedule_lead(request, slug):
|
||||
|
||||
instance.customer = lead.get_customer_model()
|
||||
|
||||
# Create AppointmentRequest
|
||||
# service,_ = Service.objects.get_or_create(name=instance.scheduled_type,duration=datetime.timedelta(minutes=10),price=0)
|
||||
service = Service.objects.get(name=instance.scheduled_type)
|
||||
# service = Service.objects.filter(name=instance.scheduled_type).first()
|
||||
# if not service:
|
||||
# messages.error(request, "Service not found!")
|
||||
# return redirect("lead_list")
|
||||
|
||||
try:
|
||||
appointment_request = AppointmentRequest.objects.create(
|
||||
@ -5203,13 +5270,16 @@ def schedule_lead(request, slug):
|
||||
|
||||
instance.save()
|
||||
messages.success(
|
||||
request, _("Lead scheduled and appointment created successfully")
|
||||
request, _("Appointment Created Successfully")
|
||||
)
|
||||
return redirect("lead_list")
|
||||
try:
|
||||
if lead.opportunity:
|
||||
return redirect("opportunity_detail", slug=lead.opportunity.slug)
|
||||
except models.Lead.opportunity.RelatedObjectDoesNotExist:
|
||||
return redirect("lead_list")
|
||||
else:
|
||||
messages.error(request, f"Invalid form data: {str(form.errors)}")
|
||||
return redirect("lead_list")
|
||||
|
||||
return redirect("schedule_lead", slug=lead.slug)
|
||||
form = forms.ScheduleForm()
|
||||
return render(request, "crm/leads/schedule_lead.html", {"lead": lead, "form": form})
|
||||
|
||||
@ -5282,9 +5352,16 @@ def send_lead_email(request, slug, email_pk=None):
|
||||
activity_type=models.ActionChoices.EMAIL,
|
||||
)
|
||||
messages.success(request, _("Email Draft successfully"))
|
||||
response = HttpResponse(redirect("lead_detail", slug=lead.slug))
|
||||
response["HX-Redirect"] = reverse("lead_detail", args=[lead.slug])
|
||||
return response
|
||||
try:
|
||||
if lead.opportunity:
|
||||
response = HttpResponse(redirect("opportunity_detail", slug=lead.opportunity.slug))
|
||||
response["HX-Redirect"] = reverse("opportunity_detail", args=[lead.opportunity.slug])
|
||||
else:
|
||||
response = HttpResponse(redirect("lead_detail", slug=lead.slug))
|
||||
response["HX-Redirect"] = reverse("lead_detail", args=[lead.slug])
|
||||
return response
|
||||
except models.Lead.opportunity.RelatedObjectDoesNotExist:
|
||||
return redirect("lead_list")
|
||||
|
||||
if request.method == "POST":
|
||||
email_pk = request.POST.get("email_pk")
|
||||
@ -5317,7 +5394,11 @@ def send_lead_email(request, slug, email_pk=None):
|
||||
activity_type=models.ActionChoices.EMAIL,
|
||||
)
|
||||
messages.success(request, _("Email sent successfully"))
|
||||
return redirect("lead_list")
|
||||
try:
|
||||
if lead.opportunity:
|
||||
return redirect("opportunity_detail", slug=lead.opportunity.slug)
|
||||
except models.Lead.opportunity.RelatedObjectDoesNotExist:
|
||||
return redirect("lead_list")
|
||||
msg = f"""
|
||||
السلام عليكم
|
||||
Dear {lead.full_name},
|
||||
@ -5415,16 +5496,20 @@ class OpportunityCreateView(CreateView, SuccessMessageMixin, LoginRequiredMixin)
|
||||
if self.kwargs.get("slug", None):
|
||||
lead = models.Lead.objects.get(slug=self.kwargs.get("slug"), dealer=dealer)
|
||||
initial["lead"] = lead
|
||||
initial["stage"] = models.Stage.PROPOSAL
|
||||
initial["stage"] = models.Stage.QUALIFICATION
|
||||
return initial
|
||||
|
||||
def form_valid(self, form):
|
||||
dealer = get_user_type(self.request)
|
||||
form.instance.dealer = dealer
|
||||
form.instance.customer = form.instance.lead.customer
|
||||
form.instance.staff = form.instance.lead.staff
|
||||
instance = form.save(commit=False)
|
||||
instance.dealer = dealer
|
||||
instance.staff = instance.lead.staff
|
||||
instance.save()
|
||||
instance.lead.convert_to_customer()
|
||||
instance.lead.save()
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy("opportunity_detail", kwargs={"slug": self.object.slug})
|
||||
|
||||
@ -5490,12 +5575,25 @@ class OpportunityDetailView(LoginRequiredMixin, DetailView):
|
||||
form.fields["stage"].widget.attrs["hx-get"] = url
|
||||
form.fields["stage"].initial = self.object.stage
|
||||
context["status_form"] = form
|
||||
|
||||
context["lead_notes"] = models.Notes.objects.filter(
|
||||
content_type__model="lead", object_id=self.object.id
|
||||
).order_by("-created")
|
||||
context["lead_notes"] = models.Notes.objects.filter(
|
||||
content_type__model="lead", object_id=self.object.lead.id
|
||||
).order_by("-created")
|
||||
context["notes"] = models.Notes.objects.filter(
|
||||
content_type__model="opportunity", object_id=self.object.id
|
||||
).order_by("-created")
|
||||
context["lead_activities"] = models.Activity.objects.filter(
|
||||
content_type__model="lead", object_id=self.object.lead.id
|
||||
)
|
||||
context["activities"] = models.Activity.objects.filter(
|
||||
content_type__model="opportunity", object_id=self.object.id
|
||||
)
|
||||
lead_email_qs = models.Email.objects.filter(
|
||||
content_type__model="lead", object_id=self.object.lead.id
|
||||
)
|
||||
email_qs = models.Email.objects.filter(
|
||||
content_type__model="opportunity", object_id=self.object.id
|
||||
)
|
||||
@ -5503,6 +5601,22 @@ class OpportunityDetailView(LoginRequiredMixin, DetailView):
|
||||
"sent": email_qs.filter(status="SENT"),
|
||||
"draft": email_qs.filter(status="DRAFT"),
|
||||
}
|
||||
context["lead_emails"] = {
|
||||
"sent": lead_email_qs.filter(status="SENT"),
|
||||
"draft": lead_email_qs.filter(status="DRAFT"),
|
||||
}
|
||||
context["staff_task_form"] = forms.StaffTaskForm()
|
||||
context["lead_tasks"] = models.Tasks.objects.filter(
|
||||
content_type__model="lead", object_id=self.object.lead.id
|
||||
)
|
||||
context["tasks"] = models.Tasks.objects.filter(
|
||||
content_type__model="opportunity", object_id=self.object.id
|
||||
)
|
||||
context["upcoming_events"] = {
|
||||
"schedules": self.object.lead.get_all_schedules().filter(
|
||||
scheduled_at__gt=timezone.now()
|
||||
),
|
||||
}
|
||||
return context
|
||||
|
||||
|
||||
@ -7885,6 +7999,7 @@ def submit_plan(request):
|
||||
|
||||
|
||||
def payment_callback(request):
|
||||
message = request.GET.get("message")
|
||||
dealer = get_user_type(request)
|
||||
payment_id = request.GET.get("id")
|
||||
history = models.PaymentHistory.objects.filter(transaction_id=payment_id).first()
|
||||
@ -7922,7 +8037,7 @@ def payment_callback(request):
|
||||
elif payment_status == "failed":
|
||||
history.status = "failed"
|
||||
history.save()
|
||||
message = request.GET.get("message")
|
||||
|
||||
return render(request, "payment_failed.html", {"message": message})
|
||||
|
||||
|
||||
@ -8072,19 +8187,14 @@ def add_task(request, content_type, slug):
|
||||
|
||||
def update_task(request, pk):
|
||||
task = get_object_or_404(models.Tasks, pk=pk)
|
||||
lead = get_object_or_404(models.Lead, pk=task.content_object.id)
|
||||
|
||||
if request.method == "POST":
|
||||
task.completed = False if task.completed else True
|
||||
task.save()
|
||||
messages.success(request, _("Task updated successfully"))
|
||||
else:
|
||||
messages.error(request, _("Task form is not valid"))
|
||||
# response = HttpResponse()
|
||||
# response['HX-Refresh'] = 'true'
|
||||
# return response
|
||||
tasks = models.Tasks.objects.filter(content_type__model="lead", object_id=lead.id)
|
||||
return render(request, "crm/leads/lead_detail.html", {"lead": lead, "tasks": tasks})
|
||||
|
||||
# tasks = models.Tasks.objects.filter(content_type__model=content_type, object_id=obj.id)
|
||||
|
||||
return render(request, "partials/task.html", {"task": task})
|
||||
|
||||
|
||||
def add_note(request, content_type, slug):
|
||||
@ -8144,6 +8254,82 @@ def user_management(request):
|
||||
}
|
||||
return render(request, "admin_management/user_management.html", context)
|
||||
|
||||
#audit log Management
|
||||
|
||||
# class AuditLogDashboardView(TemplateView):
|
||||
# """
|
||||
# Displays a dashboard with tabs for different audit log types.
|
||||
# Fetches all necessary log data to pass to the template.
|
||||
# """
|
||||
# template_name = 'admin_management/audit_log_dashboard.html' # Name of your main template with tabs
|
||||
|
||||
# def get_context_data(self, **kwargs):
|
||||
# context = super().get_context_data(**kwargs)
|
||||
|
||||
# # Fetch data for each log type
|
||||
# # Order by 'datetime' field as per easy_audit models
|
||||
# context['model_events'] = CRUDEvent.objects.all().order_by('-datetime')
|
||||
# context['auth_events'] = LoginEvent.objects.all().order_by('-datetime')
|
||||
# context['request_events'] = RequestEvent.objects.all().order_by('-datetime')
|
||||
# return context
|
||||
|
||||
|
||||
class AuditLogDashboardView(TemplateView):
|
||||
template_name = 'admin_management/audit_log_dashboard.html'
|
||||
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# Process CRUD (Model Change) Events
|
||||
model_events_raw = CRUDEvent.objects.all().order_by('-datetime')
|
||||
processed_model_events = []
|
||||
|
||||
for event in model_events_raw:
|
||||
# Create a base dictionary for each event's data
|
||||
event_data = {
|
||||
'datetime': event.datetime,
|
||||
'user': event.user,
|
||||
'event_type_display': event.get_event_type_display(),
|
||||
'model_name': event.content_type.model,
|
||||
'object_id': event.object_id,
|
||||
'object_repr': event.object_repr,
|
||||
'field_changes': [] # This will be a list of dicts: [{'field': 'name', 'old': 'A', 'new': 'B'}]
|
||||
}
|
||||
|
||||
if event.changed_fields:
|
||||
try:
|
||||
changes = json.loads(event.changed_fields)
|
||||
for field_name, values in changes.items():
|
||||
# Ensure values are lists and have at least two elements
|
||||
old_value = values[0] if isinstance(values, list) and len(values) > 0 else None
|
||||
new_value = values[1] if isinstance(values, list) and len(values) > 1 else None
|
||||
|
||||
# Store each field change as a separate entry
|
||||
event_data['field_changes'].append({
|
||||
'field': field_name,
|
||||
'old': old_value,
|
||||
'new': new_value
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
# Handle invalid JSON; you might log this error
|
||||
event_data['field_changes'].append({
|
||||
'field': 'Error',
|
||||
'old': '',
|
||||
'new': 'Invalid JSON in changed_fields'
|
||||
})
|
||||
|
||||
processed_model_events.append(event_data)
|
||||
|
||||
context['model_events'] = processed_model_events
|
||||
context['auth_events'] = LoginEvent.objects.all().order_by('-datetime')
|
||||
context['request_events'] = RequestEvent.objects.all().order_by('-datetime')
|
||||
|
||||
return context
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def activate_account(request, content_type, slug):
|
||||
try:
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -94,3 +94,4 @@ urllib3==2.3.0
|
||||
wcwidth==0.2.13
|
||||
langchain
|
||||
langchain_ollama
|
||||
django-easy-audit==1.3.7
|
||||
BIN
static/.DS_Store
vendored
BIN
static/.DS_Store
vendored
Binary file not shown.
1356
static/icons/HaikalAi.ai
Normal file
1356
static/icons/HaikalAi.ai
Normal file
File diff suppressed because one or more lines are too long
BIN
static/images/.DS_Store
vendored
BIN
static/images/.DS_Store
vendored
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
1356
staticfiles/icons/HaikalAi.ai
Normal file
1356
staticfiles/icons/HaikalAi.ai
Normal file
File diff suppressed because one or more lines are too long
BIN
staticfiles/images/favicons/haikalbot_v1.png
Normal file
BIN
staticfiles/images/favicons/haikalbot_v1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
staticfiles/images/favicons/haikalbot_v2.png
Normal file
BIN
staticfiles/images/favicons/haikalbot_v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
BIN
staticfiles/images/logos/users/li-yang-5h_dMuX_7RE-unsplash.jpg
Normal file
BIN
staticfiles/images/logos/users/li-yang-5h_dMuX_7RE-unsplash.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
BIN
staticfiles/images/logos/users/pexels-eberhardgross-443446.jpg
Normal file
BIN
staticfiles/images/logos/users/pexels-eberhardgross-443446.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
21
t1.py
Normal file
21
t1.py
Normal file
@ -0,0 +1,21 @@
|
||||
import requests
|
||||
|
||||
|
||||
def get_models_for_make():
|
||||
make = "honda"
|
||||
url = f"https://vpic.nhtsa.dot.gov/api/vehicles/GetModelsForMake/{make}/?format=json"
|
||||
|
||||
resp = requests.get(url)
|
||||
|
||||
if resp.status_code == 200:
|
||||
results = resp.json()
|
||||
return results["Results"]
|
||||
else:
|
||||
print(f"Error: {resp.status_code}")
|
||||
return {"Error": f"Request failed with status code {resp.status_code}"}
|
||||
|
||||
|
||||
|
||||
models = get_models_for_make()
|
||||
for model in models:
|
||||
print(model["Model_Name"])
|
||||
BIN
templates/.DS_Store
vendored
BIN
templates/.DS_Store
vendored
Binary file not shown.
68
templates/admin_management/audit_log_dashboard.html
Normal file
68
templates/admin_management/audit_log_dashboard.html
Normal file
@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n custom_filters %}
|
||||
{% block title %}{% trans "Accounts" %}{% endblock title %}
|
||||
{% block accounts %}
|
||||
<a class="nav-link active fw-bold">
|
||||
{% trans "Accounts"|capfirst %}
|
||||
<span class="visually-hidden">(current)</span>
|
||||
</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h3 class=""><i class="fa-solid fa-book"></i> {% trans "Audit Log Management" %}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Log Type Tabs -->
|
||||
<div class="mb-4">
|
||||
<ul class="nav nav-tabs" id="accountTypeTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="modellogs-tab" data-bs-toggle="tab" data-bs-target="#modellogs" type="button" role="tab" aria-controls="modellogs" aria-selected="true">
|
||||
<i class="fas fa-wallet me-2"></i>{% trans "User Actions" %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="authlogs-tab" data-bs-toggle="tab" data-bs-target="#authlogs" type="button" role="tab" aria-controls="authlogs" aria-selected="false">
|
||||
<i class="fas fa-boxes me-2"></i>{% trans "User Login Events" %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="requestslog-tab" data-bs-toggle="tab" data-bs-target="#requestslog" type="button" role="tab" aria-controls="requestslog" aria-selected="false">
|
||||
<i class="fas fa-landmark me-2"></i>{% trans "User Page Requests" %}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
|
||||
<div class="tab-content p-3 border border-top-0 rounded-bottom" id="accountTypeTabsContent">
|
||||
<!-- Tab -->
|
||||
<div class="tab-pane fade show active" id="modellogs" role="tabpanel" aria-labelledby="modellogs-tab">
|
||||
{% include "partials/search_box.html" %}
|
||||
{% include "admin_management/model_logs.html" %}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- COGS Tab -->
|
||||
<div class="tab-pane fade" id="authlogs" role="tabpanel" aria-labelledby="authlogs-tab">
|
||||
{% include "partials/search_box.html" %}
|
||||
{% include "admin_management/auth_logs.html" %}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Capital Tab -->
|
||||
{% comment %} <div class="tab-pane fade" id="requestslog" role="tabpanel" aria-labelledby="requestslog-tab">
|
||||
{% include "partials/search_box.html" %}
|
||||
{% include "admin_management/request_logs.html" %}
|
||||
</div> {% endcomment %}
|
||||
|
||||
<div class="tab-pane fade" id="requestslog" role="tabpanel" aria-labelledby="requestslog-tab">
|
||||
<p>Hello from Request Logs tab!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
33
templates/admin_management/auth_logs.html
Normal file
33
templates/admin_management/auth_logs.html
Normal file
@ -0,0 +1,33 @@
|
||||
{% load i18n custom_filters %}
|
||||
{% if auth_events %}
|
||||
<div class="table-responsive px-1 scrollbar mt-3">
|
||||
<table class= "table align-items-center table-flush table-hover">
|
||||
<thead>
|
||||
<tr class="bg-body-highlight">
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{{ _("Timestamp") |capfirst }}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{{ _("User") |capfirst }}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{{ _("Event Type") }}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{{ _("username") |capfirst }}</th>
|
||||
<th class="sort white-space-nowrap align-middle"scope="col">{{ _("IP Address") |capfirst }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="list">
|
||||
{% for event in auth_events %}
|
||||
<tr class="hover-actions-trigger btn-reveal-trigger position-static">
|
||||
|
||||
<td class="align-middle product white-space-nowrap">{{event.datetime}}</td>
|
||||
<td class="align-middle product white-space-nowrap">{{ event.user.username|default:"N/A" }}</td>
|
||||
<td class="align-middle product white-space-nowrap">{{ event.get_login_type_display}}</td>
|
||||
<td class="align-middle product white-space-nowrap">{{ event.username}}</td>
|
||||
<td class="align-middle product white-space-nowrap">{{ event.remote_ip}}</td>
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% else %}
|
||||
<p>No authentication audit events found.</p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@ -2,17 +2,28 @@
|
||||
{% load i18n %}
|
||||
{%block title%} {%trans 'Admin Management' %} {%endblock%}
|
||||
{% block content %}
|
||||
<h1 class="mt-4"><i class="fas fa-tools me-2"></i>Admin Management</h1>
|
||||
<div class="row row-cols-1 row-cols-md-4 g-4 mt-10">
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-4 g-4 mt-10">
|
||||
<div class="col">
|
||||
<a href="{% url 'user_management' %}">
|
||||
<div class="card h-100">
|
||||
<div class="card-header text-center">
|
||||
<h5 class="card-title">User Management</h5>
|
||||
<span style="font-size: 2rem; font-weight: 500;" class="me-2"><i class="fas fa-user"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="{% url 'user_management' %}">
|
||||
<div class="card h-100">
|
||||
<div class="card-header text-center">
|
||||
<h5 class="card-title">{{ _("User Management")}}</h5>
|
||||
<span class="me-2"><i class="fas fa-user fa-2x"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<a href="{% url 'audit_log_dashboard' %}">
|
||||
<div class="card h-100">
|
||||
<div class="card-header text-center">
|
||||
<h5 class="card-title">{{ _("Audit Log Dashboard")}}</h5>
|
||||
<span class="me-2"><i class="fas fa-user fa-2x"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock content %}
|
||||
94
templates/admin_management/model_logs.html
Normal file
94
templates/admin_management/model_logs.html
Normal file
@ -0,0 +1,94 @@
|
||||
{% load i18n custom_filters %}
|
||||
|
||||
{% if model_events %}
|
||||
<div class="table-responsive px-1 scrollbar mt-3">
|
||||
<table class="table align-items-center table-flush table-hover mt-3">
|
||||
<thead>
|
||||
<tr class="bg-body-highlight">
|
||||
<th>{% trans "Timestamp" %}</th>
|
||||
<th>{% trans "User" %}</th>
|
||||
<th>{% trans "Action" %}</th>
|
||||
<th>{% trans "Model" %}</th>
|
||||
<th>{% trans "Object ID" %}</th>
|
||||
<th>{% trans "Object Representation" %}</th>
|
||||
<th>{% trans "Field" %}</th> {# Dedicated column for field name #}
|
||||
<th>{% trans "Old Value" %}</th> {# Dedicated column for old value #}
|
||||
<th>{% trans "New Value" %}</th> {# Dedicated column for new value #}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for event in model_events %}
|
||||
{% if event.field_changes %}
|
||||
{# Loop through each individual field change for this event #}
|
||||
{% for change in event.field_changes %}
|
||||
<tr>
|
||||
{# Display common event details using rowspan for the first change #}
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ event.field_changes|length }}">
|
||||
{{ event.datetime|date:"Y-m-d H:i:s" }}
|
||||
</td>
|
||||
<td rowspan="{{ event.field_changes|length }}">
|
||||
{{ event.user.username|default:"Anonymous" }}
|
||||
</td>
|
||||
<td rowspan="{{ event.field_changes|length }}">
|
||||
{{ event.event_type_display }}
|
||||
</td>
|
||||
<td rowspan="{{ event.field_changes|length }}">
|
||||
{{ event.model_name|title }}
|
||||
</td>
|
||||
<td rowspan="{{ event.field_changes|length }}">
|
||||
{{ event.object_id }}
|
||||
</td>
|
||||
<td rowspan="{{ event.field_changes|length }}">
|
||||
{{ event.object_repr }}
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
{# Display the specific field change details in their own columns #}
|
||||
<td><strong>{{ change.field }}</strong></td>
|
||||
<td>
|
||||
{% if change.old is not None %}
|
||||
<pre style="white-space: pre-wrap; word-break: break-all; font-size: 0.85em; background-color: #f8f9fa; padding: 5px; border-radius: 3px;">{{ change.old }}</pre>
|
||||
{% else %}
|
||||
(None)
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if change.new is not None %}
|
||||
<pre style="white-space: pre-wrap; word-break: break-all; font-size: 0.85em; background-color: #f8f9fa; padding: 5px; border-radius: 3px;">{{ change.new }}</pre>
|
||||
{% else %}
|
||||
(None)
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{# Fallback for events with no specific field changes (e.g., CREATE, DELETE) #}
|
||||
<tr>
|
||||
<td>{{ event.datetime|date:"Y-m-d H:i:s" }}</td>
|
||||
<td>{{ event.user.username|default:"Anonymous" }}</td>
|
||||
<td>{{ event.event_type_display }}</td>
|
||||
<td>{{ event.model_name|title }}</td>
|
||||
<td>{{ event.object_id }}</td>
|
||||
<td>{{ event.object_repr }}</td>
|
||||
{# Span the 'Field', 'Old Value', 'New Value' columns #}
|
||||
<td colspan="3">
|
||||
{% if event.event_type_display == "Create" %}
|
||||
{% trans "Object created." %}
|
||||
{% elif event.event_type_display == "Delete" %}
|
||||
{% trans "Object deleted." %}
|
||||
{% else %}
|
||||
{% trans "No specific field changes recorded." %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{% trans "No model change audit events found." %}</p>
|
||||
{% endif %}
|
||||
|
||||
34
templates/admin_management/request_logs.html
Normal file
34
templates/admin_management/request_logs.html
Normal file
@ -0,0 +1,34 @@
|
||||
{% load i18n custom_filters %}
|
||||
{% if request_events %}
|
||||
<div class="table-responsive px-1 scrollbar mt-3">
|
||||
<table class= "table align-items-center table-flush table-hover">
|
||||
<thead>
|
||||
<tr class="bg-body-highlight">
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{{ _("Timestamp") |capfirst }}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{{ _("User") |capfirst }}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{{ _("URL") }}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{{ _("Method") |capfirst }}</th>
|
||||
<th class="sort white-space-nowrap align-middle"scope="col">{{ _("IP Address") |capfirst }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="list">
|
||||
{% for event in request_events %}
|
||||
<tr class="hover-actions-trigger btn-reveal-trigger position-static">
|
||||
|
||||
<td class="align-middle product white-space-nowrap">{{event.datetime}}</td>
|
||||
<td class="align-middle product white-space-nowrap">{{ event.user.username|default:"Anonymous" }}</td>
|
||||
<td class="align-middle product white-space-nowrap">{{ event.url }}</td>
|
||||
<td class="align-middle product white-space-nowrap">{{ event.method}}</td>
|
||||
<td class="align-middle product white-space-nowrap">{{ event.remote_ip}}</td>
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<p>No request audit events found.</p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
{% block content %}
|
||||
<section class="pt-5 pb-9">
|
||||
<div class="row">
|
||||
<h2 class="mb-4"><i class="fa-solid fa-people-roof me-1"></i>{% trans 'User Management' %}</h2>
|
||||
<h2 class="mb-4"><i class="fa-solid fa-people-roof me-1"></i> {% trans 'User Management' %}</h2>
|
||||
<div class="row g-3 justify-content-between mb-4">
|
||||
<div class="col-12">
|
||||
<h3 class="mb-3">{% trans 'Customers' %}</h3>
|
||||
|
||||
21
templates/components/task_modal.html
Normal file
21
templates/components/task_modal.html
Normal file
@ -0,0 +1,21 @@
|
||||
{% load static i18n crispy_forms_tags %}
|
||||
<!-- task Modal -->
|
||||
<div class="modal fade" id="taskModal" tabindex="-1" aria-labelledby="taskModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-md">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header justify-content-between align-items-start gap-5 px-4 pt-4 pb-3 border-0">
|
||||
<h4 class="modal-title" id="taskModalLabel">{% trans 'Task' %}</h4>
|
||||
<button class="btn p-0 text-body-quaternary fs-6" data-bs-dismiss="modal" aria-label="Close">
|
||||
<span class="fas fa-times"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form action="{% url 'add_task' content_type slug %}" method="post" class="add_task_form">
|
||||
{% csrf_token %}
|
||||
{{ staff_task_form|crispy }}
|
||||
<button type="submit" class="btn btn-success w-100">{% trans 'Save' %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -38,60 +38,7 @@
|
||||
{% block content %}
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="modal fade" id="actionTrackingModal" tabindex="-1" aria-labelledby="actionTrackingModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="actionTrackingModalLabel">{{ _("Update Lead Actions") }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form id="actionTrackingForm" method="post">
|
||||
<div class="modal-body">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" id="leadId" name="lead_id">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="currentAction" class="form-label">{{ _("Current Action") }}</label>
|
||||
<select class="form-select" id="currentAction" name="current_action" required>
|
||||
<option value="">{{ _("Select Action") }}</option>
|
||||
<option value="follow_up">{{ _("Follow Up") }}</option>
|
||||
<option value="negotiation">{{ _("Negotiation") }}</option>
|
||||
<option value="won">{{ _("Won") }}</option>
|
||||
<option value="lost">{{ _("Lost") }}</option>
|
||||
<option value="closed">{{ _("Closed") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="nextAction" class="form-label">{{ _("Next Action") }}</label>
|
||||
<select class="form-select" id="nextAction" name="next_action" required>
|
||||
<option value="">{{ _("Select Next Action") }}</option>
|
||||
<option value="no_action">{{ _("No Action") }}</option>
|
||||
<option value="call">{{ _("Call") }}</option>
|
||||
<option value="meeting">{{ _("Meeting") }}</option>
|
||||
<option value="email">{{ _("Email") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="nextActionDate" class="form-label">{{ _("Next Action Date") }}</label>
|
||||
<input type="datetime-local" class="form-control" id="nextActionDate" name="next_action_date">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="actionNotes" class="form-label">{{ _("Notes") }}</label>
|
||||
<textarea class="form-control" id="actionNotes" name="action_notes" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ _("Close") }}</button>
|
||||
<button type="submit" class="btn btn-primary">{{ _("Save Changes") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "crm/leads/partials/update_action.html" %}
|
||||
<div class="row align-items-center justify-content-between g-3 mb-3">
|
||||
<div class="col-12 col-md-auto">
|
||||
<h4 class="mb-0">{{ _("Lead Details")}}</h4>
|
||||
@ -130,16 +77,14 @@
|
||||
<h5 class="text-body-highlight mb-0 text-end">{{ _("Status")}}
|
||||
{% if lead.status == "new" %}
|
||||
<span class="badge badge-phoenix badge-phoenix-primary"><span class="badge-label">{{_("New")}}</span><span class="fa fa-bell ms-1"></span></span>
|
||||
{% elif lead.status == "follow_up" %}
|
||||
<span class="badge badge-phoenix badge-phoenix-warning"><span class="badge-label">{{_("Follow Up")}}</span><span class="fa fa-clock-o ms-1"></span></span>
|
||||
{% elif lead.status == "negotiation" %}
|
||||
<span class="badge badge-phoenix badge-phoenix-info"><span class="badge-label">{{_("Negotiation")}}</span><span class="fa fa-wrench ms-1"></span></span>
|
||||
{% elif lead.status == "won" %}
|
||||
<span class="badge badge-phoenix badge-phoenix-success"><span class="badge-label">{{_("Won")}}</span><span class="fa fa-check ms-1"></span></span>
|
||||
{% elif lead.status == "lost" %}
|
||||
<span class="badge badge-phoenix badge-phoenix-danger"><span class="badge-label">{{_("Lost")}}</span><span class="fa fa-times ms-1"></span></span>
|
||||
{% elif lead.status == "closed" %}
|
||||
<span class="badge badge-phoenix badge-phoenix-danger"><span class="badge-label">{{_("Closed")}}</span><span class="fa fa-times ms-1"></span></span>
|
||||
{% elif lead.status == "contacted" %}
|
||||
<span class="badge badge-phoenix badge-phoenix-warning"><span class="badge-label">{{_("Contacted")}}</span><span class="fa fa-clock-o ms-1"></span></span>
|
||||
{% elif lead.status == "qualified" %}
|
||||
<span class="badge badge-phoenix badge-phoenix-info"><span class="badge-label">{{_("Qualified")}}</span><span class="fa fa-wrench ms-1"></span></span>
|
||||
{% elif lead.status == "unqualified" %}
|
||||
<span class="badge badge-phoenix badge-phoenix-success"><span class="badge-label">{{_("Unqualified")}}</span><span class="fa fa-check ms-1"></span></span>
|
||||
{% elif lead.status == "converted" %}
|
||||
<span class="badge badge-phoenix badge-phoenix-danger"><span class="badge-label">{{_("Converted")}}</span><span class="fa fa-times ms-1"></span></span>
|
||||
{% endif %}
|
||||
</h5>
|
||||
</div>
|
||||
@ -149,9 +94,25 @@
|
||||
<div class="card mb-2">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center g-3 text-center text-xxl-start">
|
||||
<div class="col-6 col-sm-auto flex-1">
|
||||
<h5 class="fw-bolder mb-2">{{ _("Car Requested") }}</h5>
|
||||
{{ lead.id_car_make.get_local_name }} - {{ lead.id_car_model.get_local_name }} {{ lead.year }}
|
||||
<div class="col-6 col-sm-auto d-flex flex-column align-items-center text-center">
|
||||
<h5 class="fw-bolder mb-2 text-body-highlight">{{ _("Car Requested") }}</h5>
|
||||
<img src="{{ lead.id_car_make.logo.url }}" alt="Car Make Logo" class="img-fluid rounded mb-2" style="width: 60px; height: 60px;">
|
||||
<p class="mb-0">{{ lead.id_car_make.get_local_name }} - {{ lead.id_car_model.get_local_name }} {{ lead.year }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-2">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center g-3 text-center text-xxl-start">
|
||||
<div class="col-6 col-sm-auto d-flex flex-column align-items-center text-center">
|
||||
<h5 class="fw-bolder mb-2 text-body-highlight">{{ _("Related Records") }}</h5>
|
||||
<h6 class="fw-bolder mb-2 text-body-highlight">{{ _("Opportunity") }}</h6>
|
||||
{% if lead.opportunity %}
|
||||
<a href="{% url 'opportunity_detail' lead.opportunity.slug %}" class="">{{ lead.opportunity }}</a>
|
||||
{% else %}
|
||||
<p>{{ _("No Opportunity") }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -170,12 +131,6 @@
|
||||
</div>
|
||||
<span class="text-body-secondary">{{ lead.phone_number}} </span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center mb-1"><span class="currency">{{CURRENCY}}</span>
|
||||
<h5 class="text-body-highlight fw-bold mb-0">{{ _("Salary")}}</h5>
|
||||
</div>
|
||||
<p class="mb-0 text-body-secondary"><small><span class="currency">{{CURRENCY}}</span></small> {{lead.salary}} </p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center mb-1"><span class="me-2 uil uil-clock"></span>
|
||||
<h5 class="text-body-highlight fw-bold mb-0">{{ _("Created")}}</h5>
|
||||
@ -200,12 +155,6 @@
|
||||
</div>
|
||||
<span class="text-body-secondary">{{ lead.address}}</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center mb-1"><span class="me-2 uil uil-map"></span>
|
||||
<h5 class="text-body-highlight fw-bold mb-0">{{ _("City") }}</h5>
|
||||
</div>
|
||||
<span class="text-body-secondary">{{ lead.city }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -218,12 +167,12 @@
|
||||
<div class="kanban-header bg-secondary w-50 text-white fw-bold"><i class="fa-solid fa-circle-info me-2"></i>{{lead.next_action|capfirst}} <br> <small>{% trans "Next Action" %} :</small> <small>{{lead.next_action_date|naturalday|capfirst}}</small></div>
|
||||
</div>
|
||||
<ul class="nav main-tab nav-underline fs-9 deal-details scrollbar flex-nowrap w-100 pb-1 mb-6 justify-content-end mt-5" id="myTab" role="tablist" style="overflow-y: hidden;">
|
||||
<li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link active" id="activity-tab" data-bs-toggle="tab" href="#tab-activity" role="tab" aria-controls="tab-activity" aria-selected="false" tabindex="-1"> <span class="fa-solid fa-chart-line me-2 tab-icon-color fs-8"></span>{{ _("Activity") }}</a></li>
|
||||
<li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link active" id="opportunity-tab" data-bs-toggle="tab" href="#tab-opportunity" role="tab" aria-controls="tab-opportunity" aria-selected="false" tabindex="-1"> <span class="fa-solid fa-chart-line me-2 tab-icon-color fs-8"></span>{{ _("Opportunities") }}</a></li>
|
||||
<li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link" id="activity-tab" data-bs-toggle="tab" href="#tab-activity" role="tab" aria-controls="tab-activity" aria-selected="false" tabindex="-1"> <span class="fa-solid fa-chart-line me-2 tab-icon-color fs-8"></span>{{ _("Activity") }}</a></li>
|
||||
<li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link" id="notes-tab" data-bs-toggle="tab" href="#tab-notes" role="tab" aria-controls="tab-notes" aria-selected="false" tabindex="-1"> <span class="fa-solid fa-clipboard me-2 tab-icon-color fs-8"></span>{{ _("Notes") }}</a></li>
|
||||
<li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link" id="emails-tab" data-bs-toggle="tab" href="#tab-emails" role="tab" aria-controls="tab-emails" aria-selected="true"> <span class="fa-solid fa-envelope me-2 tab-icon-color fs-8"></span>{{ _("Emails") }}</a></li>
|
||||
<li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link" id="tasks-tab" data-bs-toggle="tab" href="#tab-tasks" role="tab" aria-controls="tab-tasks" aria-selected="true"> <span class="fa-solid fa-envelope me-2 tab-icon-color fs-8"></span>{{ _("Tasks") }}</a></li>
|
||||
<li class="nav-item text-nowrap ml-auto" role="presentation">
|
||||
<a href="{% url 'opportunity_create' %}" class="btn btn-info btn-sm" type="button"> <i class="fa-solid fa-user-plus me-2"></i> Create Opportunity</a>
|
||||
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#exampleModal"> <i class="fa-solid fa-user-plus me-2"></i> Reassign Lead</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="openActionModal('{{ lead.id }}', '{{ lead.action }}', '{{ lead.next_action }}', '{{ lead.next_action_date|date:"Y-m-d\TH:i" }}')">
|
||||
<i class="fa-solid fa-user-plus me-2"></i>
|
||||
@ -252,9 +201,9 @@
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content" id="myTabContent">
|
||||
<div class="tab-pane fade active show" id="tab-activity" role="tabpanel" aria-labelledby="activity-tab">
|
||||
<div class="tab-pane fade" id="tab-activity" role="tabpanel" aria-labelledby="activity-tab">
|
||||
<div class="mb-1 d-flex justify-content-between align-items-center">
|
||||
<h3 class="mb-4" id="scrollspyTask">{{ _("Activities") }} <span class="fw-light fs-7">({{ activities.count}})</span></h3>
|
||||
<h3 class="mb-4" id="s crollspyTask">{{ _("Activities") }} <span class="fw-light fs-7">({{ activities.count}})</span></h3>
|
||||
<button class="btn btn-phoenix-primary btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#activityModal"><span class="fas fa-plus me-1"></span>{{ _("Add Activity") }}</button>
|
||||
</div>
|
||||
<div class="row justify-content-between align-items-md-center hover-actions-trigger btn-reveal-trigger border-translucent py-3 gx-0 border-top">
|
||||
@ -300,6 +249,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade active show" id="tab-opportunity" role="tabpanel" aria-labelledby="opportunity-tab">
|
||||
<div class="mb-1 d-flex justify-content-between align-items-center">
|
||||
<h3 class="mb-4" id="scrollspyTask">{{ _("Opportunities") }} <span class="fw-light fs-7">({{ lead.get_opportunities.count}})</span></h3>
|
||||
<a href="{% url 'opportunity_create' %}" class="btn btn-phoenix-primary btn-sm" type="button"> <i class="fa-solid fa-plus me-2"></i>{{ _("Add Opportunity") }}</a>
|
||||
</div>
|
||||
|
||||
<div class="border-top border-bottom border-translucent" id="leadDetailsTable">
|
||||
<div class="table-responsive scrollbar mx-n1 px-1">
|
||||
<table class="table fs-9 mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="align-middle pe-6 text-uppercase text-start" scope="col" style="width:40%;">{{ _("Car") }}</th>
|
||||
<th class="align-middle text-start text-uppercase" scope="col" style="width:20%;">{{ _("Probability")}}</th>
|
||||
<th class="align-middle text-start text-uppercase white-space-nowrap" scope="col" style="width:20%;">{{ _("Priority")}}</th>
|
||||
<th class="align-middle pe-0 text-end" scope="col" style="width:10%;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody >
|
||||
{% for opportunity in lead.get_opportunities %}
|
||||
<tr class="hover-actions-trigger btn-reveal-trigger position-static">
|
||||
<td class="align-middle text-start fw-bold text-body-tertiary ps-1">{{opportunity.car}}</td>
|
||||
<td class="align-middle text-start fw-bold text-body-tertiary ps-1">{{opportunity.probability}}</td>
|
||||
<td class="align-middle text-start fw-bold text-body-tertiary ps-1">{{opportunity.priority|capfirst}}</td>
|
||||
<td class="align-middle text-start fw-bold text-body-tertiary ps-1"><a class="btn btn-sm btn-phoenix-primary" href="{% url 'opportunity_detail' opportunity.slug %}">View</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-notes" role="tabpanel" aria-labelledby="notes-tab">
|
||||
<div class="mb-1 d-flex align-items-center justify-content-between">
|
||||
<h3 class="mb-4" id="scrollspyNotes">{{ _("Notes") }}</h3>
|
||||
@ -319,7 +300,6 @@
|
||||
</thead>
|
||||
<tbody >
|
||||
{% for note in notes %}
|
||||
|
||||
<tr class="hover-actions-trigger btn-reveal-trigger position-static">
|
||||
<td class="align-middle text-start fw-bold text-body-tertiary ps-1">{{note.note}}</td>
|
||||
{% if note.created_by.staff %}
|
||||
@ -502,27 +482,9 @@
|
||||
<th class="sort align-middle text-start text-uppercase" scope="col" data-sort="date" style="min-width:165px">Completed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="list" id="all-email-table-body">
|
||||
<tbody class="list" id="all-tasks-table-body">
|
||||
{% for task in tasks %}
|
||||
<tr class="task-{{task.pk}} hover-actions-trigger btn-reveal-trigger position-static {% if task.completed %}completed-task{% endif %}">
|
||||
<td class="fs-9 align-middle px-0 py-3">
|
||||
<div class="form-check mb-0 fs-8">
|
||||
<input class="form-check-input" type="checkbox" hx-post="{% url 'update_task' task.pk %}" hx-trigger="change" hx-swap="outerHTML" hx-select=".task-{{task.pk}}" hx-target=".task-{{task.pk}}" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="subject order align-middle white-space-nowrap py-2 ps-0"><a class="fw-semibold text-primary" href="">{{task.title}}</a>
|
||||
<div class="fs-10 d-block">{{task.description}}</div>
|
||||
</td>
|
||||
<td class="sent align-middle white-space-nowrap text-start fw-bold text-body-tertiary py-2">{{task.assigned_to}}</td>
|
||||
<td class="date align-middle white-space-nowrap text-body py-2">{{task.created|naturalday|capfirst}}</td>
|
||||
<td class="date align-middle white-space-nowrap text-body py-2">
|
||||
{% if task.completed %}
|
||||
<span class="badge badge-phoenix fs-10 badge-phoenix-success"><i class="fa-solid fa-check"></i></span>
|
||||
{% else %}
|
||||
<span class="badge badge-phoenix fs-10 badge-phoenix-warning"><i class="fa-solid fa-xmark"></i></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% include "partials/task.html" %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@ -6,60 +6,7 @@
|
||||
<div class="row g-3">
|
||||
<h2 class="mb-4">{{ _("Leads")|capfirst }}</h2>
|
||||
<!-- Action Tracking Modal -->
|
||||
<div class="modal fade" id="actionTrackingModal" tabindex="-1" aria-labelledby="actionTrackingModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="actionTrackingModalLabel">{{ _("Update Lead Actions") }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form id="actionTrackingForm" method="post">
|
||||
<div class="modal-body">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" id="leadId" name="lead_id">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="currentAction" class="form-label">{{ _("Current Action") }}</label>
|
||||
<select class="form-select" id="currentAction" name="current_action" required>
|
||||
<option value="">{{ _("Select Action") }}</option>
|
||||
<option value="contacted">{{ _("Contacted") }}</option>
|
||||
<option value="follow_up">{{ _("Follow Up") }}</option>
|
||||
<option value="proposal_sent">{{ _("Proposal Sent") }}</option>
|
||||
<option value="negotiation">{{ _("Negotiation") }}</option>
|
||||
<option value="closed">{{ _("Closed") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="nextAction" class="form-label">{{ _("Next Action") }}</label>
|
||||
<select class="form-select" id="nextAction" name="next_action" required>
|
||||
<option value="">{{ _("Select Next Action") }}</option>
|
||||
<option value="call">{{ _("Call") }}</option>
|
||||
<option value="meeting">{{ _("Meeting") }}</option>
|
||||
<option value="email">{{ _("Email") }}</option>
|
||||
<option value="proposal">{{ _("Send Proposal") }}</option>
|
||||
<option value="follow_up">{{ _("Follow Up") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="nextActionDate" class="form-label">{{ _("Next Action Date") }}</label>
|
||||
<input type="datetime-local" class="form-control" id="nextActionDate" name="next_action_date" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="actionNotes" class="form-label">{{ _("Notes") }}</label>
|
||||
<textarea class="form-control" id="actionNotes" name="action_notes" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">{{ _("Close") }}</button>
|
||||
<button type="submit" class="btn btn-success">{{ _("Save Changes") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "crm/leads/partials/update_action.html" %}
|
||||
|
||||
<div class="row g-3 justify-content-between mb-4">
|
||||
<div class="col-auto">
|
||||
@ -201,15 +148,15 @@
|
||||
{% if schedule.scheduled_type == "call" %}
|
||||
<a href="{% url 'appointment:get_user_appointments' %}">
|
||||
<span class="badge badge-phoenix badge-phoenix-primary text-primary {% if schedule.schedule_past_date %}badge-phoenix-danger text-danger{% endif %} fw-semibold"><span class="text-primary {% if schedule.schedule_past_date %}text-danger{% endif %}" data-feather="phone"></span>
|
||||
{{ schedule.scheduled_at|naturalday|capfirst }}</span></a>
|
||||
{{ schedule.scheduled_at|naturaltime|capfirst }}</span></a>
|
||||
{% elif schedule.scheduled_type == "meeting" %}
|
||||
<a href="{% url 'appointment:get_user_appointments' %}">
|
||||
<span class="badge badge-phoenix badge-phoenix-success text-success fw-semibold"><span class="text-success" data-feather="calendar"></span>
|
||||
{{ schedule.scheduled_at|naturalday|capfirst }}</span></a>
|
||||
{{ schedule.scheduled_at|naturaltime|capfirst }}</span></a>
|
||||
{% elif schedule.scheduled_type == "email" %}
|
||||
<a href="{% url 'appointment:get_user_appointments' %}">
|
||||
<span class="badge badge-phoenix badge-phoenix-warning text-warning fw-semibold"><span class="text-warning" data-feather="email"></span>
|
||||
{{ schedule.scheduled_at|naturalday|capfirst }}</span></a>
|
||||
{{ schedule.scheduled_at|naturaltime|capfirst }}</span></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'lead_detail' lead.slug %}" class="btn btn-link text-body fs-10 text-decoration-none">Discard</a>
|
||||
<a href="{{ request.META.HTTP_REFERER }}" class="btn btn-link text-body fs-10 text-decoration-none">Discard</a>
|
||||
<a hx-boost="true" hx-push-url='false' hx-include="#message,#subject,#to" href="{% url 'send_lead_email' lead.slug %}?status=draft" class="btn btn-secondary text-white fs-10 text-decoration-none">Save as Draft</a>
|
||||
<button class="btn btn-primary fs-10" type="submit">Send<span class="fa-solid fa-paper-plane ms-1"></span></button>
|
||||
</div>
|
||||
|
||||
@ -10,14 +10,20 @@
|
||||
min-height: 500px;
|
||||
}
|
||||
.kanban-header {
|
||||
position: relative;
|
||||
font-weight: 600;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
clip-path: polygon(0 0, calc(100% - 15px) 0, 100% 50%, calc(100% - 15px) 100%, 0 100%);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
}
|
||||
position: relative;
|
||||
font-weight: 600;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
--pointed-edge: {% if LANGUAGE_CODE == 'en' %} right {% else %} left {% endif %};
|
||||
clip-path: {% if LANGUAGE_CODE == 'en' %}
|
||||
polygon(0 0, calc(100% - 15px) 0, 100% 50%, calc(100% - 15px) 100%, 0 100%)
|
||||
{% else %}
|
||||
polygon(15px 0, 100% 0, 100% 100%, 15px 100%, 0 50%)
|
||||
{% endif %};
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
|
||||
.kanban-header::after {
|
||||
content: "";
|
||||
@ -69,7 +75,7 @@
|
||||
<!-- New Lead -->
|
||||
<div class="col-md">
|
||||
<div class="kanban-column bg-body">
|
||||
<div class="kanban-header bg-secondary-light">{{ _("New Leads")}} ({{new|length}})</div>
|
||||
<div class="kanban-header opacity-75"><span class="text-body">{{ _("New Leads")}} ({{new|length}})</span></div>
|
||||
{% for lead in new %}
|
||||
<a href="{% url 'lead_detail' lead.slug %}">
|
||||
<div class="lead-card">
|
||||
@ -85,7 +91,7 @@
|
||||
<!-- Follow Ups -->
|
||||
<div class="col-md">
|
||||
<div class="kanban-column bg-body">
|
||||
<div class="kanban-header bg-info-light">{{ _("Follow Ups")}} ({{follow_up|length}})</div>
|
||||
<div class="kanban-header opacity-75"><span class="text-body">{{ _("Follow Ups")}} ({{follow_up|length}})</span></div>
|
||||
{% for lead in follow_up %}
|
||||
<a href="{% url 'lead_detail' lead.slug %}">
|
||||
<div class="lead-card">
|
||||
@ -101,7 +107,7 @@
|
||||
<!-- Negotiation -->
|
||||
<div class="col-md">
|
||||
<div class="kanban-column bg-body">
|
||||
<div class="kanban-header bg-negotiation-soft">{{ _("Negotiation") }} ({{negotiation|length}})</div>
|
||||
<div class="kanban-header opacity-75"><span class="text-body">{{ _("Negotiation Ups")}} ({{follow_up|length}})</span></div>
|
||||
{% for lead in negotiation %}
|
||||
<a href="{% url 'lead_detail' lead.slug %}">
|
||||
<div class="lead-card">
|
||||
@ -117,7 +123,7 @@
|
||||
<!-- Won -->
|
||||
<div class="col-md">
|
||||
<div class="kanban-column bg-body">
|
||||
<div class="kanban-header bg-success-soft">{{ _("Won") }} ({{won|length}})</div>
|
||||
<div class="kanban-header bg-success-light opacity-75"><span class="text-body">{{ _("Won") }} ({{won|length}}) ({{follow_up|length}})</span></div>
|
||||
{% for lead in won %}
|
||||
<a href="{% url 'lead_detail' lead.slug %}">
|
||||
<div class="lead-card">
|
||||
@ -133,7 +139,7 @@
|
||||
<!-- Lose -->
|
||||
<div class="col-md">
|
||||
<div class="kanban-column bg-body">
|
||||
<div class="kanban-header bg-danger-soft">{{ _("Lost") }} ({{lose|length}})</div>
|
||||
<div class="kanban-header bg-danger-light opacity-75">{{ _("Lost") }} ({{lose|length}})</div>
|
||||
{% for lead in lose %}
|
||||
<a href="{% url 'lead_detail' lead.slug %}">
|
||||
<div class="lead-card">
|
||||
|
||||
53
templates/crm/leads/partials/update_action.html
Normal file
53
templates/crm/leads/partials/update_action.html
Normal file
@ -0,0 +1,53 @@
|
||||
<div class="modal fade" id="actionTrackingModal" tabindex="-1" aria-labelledby="actionTrackingModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="actionTrackingModalLabel">{{ _("Update Lead Actions") }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form id="actionTrackingForm" method="post">
|
||||
<div class="modal-body">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" id="leadId" name="lead_id">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="currentAction" class="form-label">{{ _("Current Stage") }}</label>
|
||||
<select class="form-select" id="currentAction" name="current_action" required>
|
||||
<option value="">{{ _("Select Stage") }}</option>
|
||||
<option value="new">{{ _("New") }}</option>
|
||||
<option value="contacted">{{ _("Contacted") }}</option>
|
||||
<option value="qualified">{{ _("Qualified") }}</option>
|
||||
<option value="unqualified">{{ _("Unqualified") }}</option>
|
||||
<option value="converted">{{ _("Converted") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="nextAction" class="form-label">{{ _("Next Action") }}</label>
|
||||
<select class="form-select" id="nextAction" name="next_action" required>
|
||||
<option value="">{{ _("Select Next Action") }}</option>
|
||||
<option value="no_action">{{ _("No Action") }}</option>
|
||||
<option value="call">{{ _("Call") }}</option>
|
||||
<option value="meeting">{{ _("Meeting") }}</option>
|
||||
<option value="email">{{ _("Email") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="nextActionDate" class="form-label">{{ _("Next Action Date") }}</label>
|
||||
<input type="datetime-local" class="form-control" id="nextActionDate" name="next_action_date">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="actionNotes" class="form-label">{{ _("Notes") }}</label>
|
||||
<textarea class="form-control" id="actionNotes" name="action_notes" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ _("Close") }}</button>
|
||||
<button type="submit" class="btn btn-primary">{{ _("Save Changes") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -15,10 +15,11 @@
|
||||
{% if opportunity.estimate %}
|
||||
<a class="dropdown-item" href="{% url 'estimate_detail' opportunity.estimate.pk %}">{{ _("View Quotation")}}</a>
|
||||
{% else %}
|
||||
<a class="dropdown-item" href="{% url 'estimate_create_from_opportunity' opportunity.pk %}">{{ _("Create Quotation")}}</a>
|
||||
<a class="dropdown-item" href="{% url 'estimate_create_from_opportunity' opportunity.slug %}">{{ _("Create Quotation")}}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
<li><a class="dropdown-item" href="{% url 'update_opportunity' opportunity.slug %}">Update Opportunity</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'update_opportunity' opportunity.slug %}">Update Stage</a></li>
|
||||
<li><a class="dropdown-item text-danger" href="">Delete Opportunity</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -32,9 +33,9 @@
|
||||
<div class="row align-items-center g-3">
|
||||
<div class="col-12 col-sm-auto flex-1">
|
||||
{% if opportunity.car %}
|
||||
<h3 class="fw-bolder mb-2 line-clamp-1">{{ opportunity.car.id_car_make.get_local_name }} - {{ opportunity.car.id_car_model.get_local_name }} - {{ opportunity.car.year }}</h3>
|
||||
<h3 class="fw-bolder mb-2 line-clamp-1"><span class="d-inline-block lh-sm me-1" data-feather="check-circle" style="height:16px;width:16px;"></span> {{ opportunity.car.id_car_make.get_local_name }} - {{ opportunity.car.id_car_model.get_local_name }} - {{ opportunity.car.year }}</h3>
|
||||
{% endif %}
|
||||
<h3 class="fw-bolder mb-2 line-clamp-1">{{ opportunity.customer.customer_name }}</h3>
|
||||
|
||||
<div class="d-flex align-items-center mb-4">
|
||||
{% if opportunity.car.finances %}
|
||||
<h5 class="mb-0 me-4">{{ opportunity.car.finances.total }} <span class="fw-light"><span class="currency">{{ CURRENCY }}</span></span></h5>
|
||||
@ -48,60 +49,101 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h5>{{ opportunity.staff.get_local_name}}</h5>
|
||||
<div class="dropdown"><a class="text-body-secondary dropdown-toggle text-decoration-none dropdown-caret-none" href="#!" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Owner<span class="fa-solid fa-caret-down text-body-secondary fs-9 ms-2"></span></a>
|
||||
<div class="dropdown-menu shadow-sm" style="min-width:20rem">
|
||||
<div class="card position-relative border-0">
|
||||
<div class="card-body p-0">
|
||||
<div class="mx-3">
|
||||
<div class="text-end">
|
||||
<button class="btn btn-link text-danger" type="button">{{ _("Cancel") }}</button>
|
||||
<button class="btn btn-sm btn-primary px-5" type="button">{{ _("Save") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if opportunity.customer %}
|
||||
<h5>{{ opportunity.customer|capfirst}}</h5>
|
||||
<div class=""><div class="text-body-secondary text-decoration-none">Individual<span class="fa-solid text-body-secondary fs-9 ms-2"></span></div>
|
||||
{% else %}
|
||||
<h5>{{ opportunity.organization|capfirst}}</h5>
|
||||
<div class=""><div class="text-body-secondary text-decoration-none">Organization<span class="fa-solid text-body-secondary fs-9 ms-2"></span></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div><span class="badge badge-phoenix badge-phoenix-success me-2">{{ opportunity.get_stage_display }}</span><span class="badge badge-phoenix badge-phoenix-danger me-2">{{ opportunity.get_status_display }}</span></div>
|
||||
<div><span class="badge badge-phoenix badge-phoenix-primary">STAGE</span> : <span class="badge badge-phoenix badge-phoenix-success me-2">{{ opportunity.get_stage_display }}</span><span class="badge badge-phoenix badge-phoenix-danger me-2">{{ opportunity.get_status_display }}</span></div>
|
||||
</div>
|
||||
<div class="progress mb-2" style="height:5px">
|
||||
<div class="progress-bar bg-primary-lighter" data-bs-theme="light" role="progressbar" style="width: {{ opportunity.probability }}%" aria-valuenow="25" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<p class="mb-0"> {{ opportunity.get_status_display }}</p>
|
||||
<div><span class="d-inline-block lh-sm me-1" data-feather="clock" style="height:16px;width:16px;"></span><span class="d-inline-block lh-sm"> {{ opportunity.created}}</span></div>
|
||||
<div><span class="d-inline-block lh-sm me-1" data-feather="clock" style="height:16px;width:16px;"></span><span class="d-inline-block lh-sm"> {{ opportunity.created|naturaltime|capfirst}}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% comment %} <div class="card">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h4 class="mb-5">{{ _("Other Information")}}</h4>
|
||||
<h4 class="mb-5 d-flex align-items-center"><span class="d-inline-block lh-sm me-1" data-feather="link" style="height:16px;width:16px;"></span> {{ _("Upcoming Events")}}</h4>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 overflow-auto" style="max-height: 200px;">
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for event in opportunity.get_schedules %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge rounded-pill bg-phoenix-primary text-primary me-2 fs-9">{{ event.scheduled_type|capfirst }}</span>
|
||||
<span class="fs-9">{{ event.purpose }}</span>
|
||||
</div>
|
||||
<div class="fs-9">{{ event.scheduled_at|naturaltime|capfirst }}</div>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="list-group-item text-center fs-9">{{ _("No upcoming events") }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="mb-5 d-flex align-items-center"><span class="d-inline-block lh-sm me-1" data-feather="link" style="height:16px;width:16px;"></span> {{ _("Related Records")}}</h4>
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="mb-4">
|
||||
<div class="d-flex flex-wrap justify-content-between mb-2">
|
||||
<h5 class="mb-0 text-body-highlight me-2">{{ _("Status") }}</h5>
|
||||
<a href="#" class="fw-bold fs-9" hx-on:click="htmx.find('#id_status').disabled = !htmx.find('#id_status').disabled;this.text = htmx.find('#id_status').disabled ? 'Update Status' : 'Cancel'">{{ _("Update Status")}}</a>
|
||||
<h5 class="mb-0 text-body-highlight me-2">{{ _("Estimate") }}</h5>
|
||||
</div>
|
||||
{{status_form.status}}
|
||||
{% if opportunity.estimate %}
|
||||
<a class="dropdown-item" href="{% url 'estimate_detail' opportunity.estimate.pk %}">{{ _("View Quotation")}}</a>
|
||||
{% else %}
|
||||
<p>{{ _("No Estimate") }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="d-flex flex-wrap justify-content-between mb-2">
|
||||
<h5 class="mb-0 text-body-highlight me-2">{{ _("Stage") }}</h5>
|
||||
<a href="#" class="fw-bold fs-9" hx-on:click="htmx.find('#id_stage').disabled = !htmx.find('#id_stage').disabled;this.text = htmx.find('#id_stage').disabled ? 'Update Stage' : 'Cancel'">{{ _("Update Stage")}}</a>
|
||||
<h5 class="mb-0 text-body-highlight me-2">{{ _("Invoice") }}</h5>
|
||||
</div>
|
||||
{{status_form.stage}}
|
||||
{% if opportunity.estimate.invoice %}
|
||||
<a class="dropdown-item" href="{% url 'invoice_detail' opportunity.estimate.invoice.pk %}">{{ _("View Invoice")}}</a>
|
||||
{% else %}
|
||||
<p>{{ _("No Invoice") }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> {% endcomment %}
|
||||
</div>
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h4 class="mb-5 d-flex align-items-center"><span class="d-inline-block lh-sm me-1" data-feather="clock" style="height:16px;width:16px;"></span> {{ _("System Information")}}</h4>
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="mb-4">
|
||||
<div class="d-flex flex-wrap justify-content-between mb-2">
|
||||
<h5 class="mb-0 text-body-highlight me-2">{{ _("Created ") }}</h5>
|
||||
</div>
|
||||
{{ opportunity.created|naturalday|capfirst }}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="d-flex flex-wrap justify-content-between mb-2">
|
||||
<h5 class="mb-0 text-body-highlight me-2">{{ _("Last Updated") }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
{{ opportunity.updated }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-7 col-xxl-8">
|
||||
@ -110,7 +152,7 @@
|
||||
<div class="row g-4 g-xl-1 g-xxl-3 justify-content-between">
|
||||
<div class="col-sm-auto">
|
||||
<div class="d-sm-block d-inline-flex d-md-flex flex-xl-column flex-xxl-row align-items-center align-items-xl-start align-items-xxl-center">
|
||||
<div class="d-flex bg-success-subtle rounded flex-center me-3 mb-sm-3 mb-md-0 mb-xl-3 mb-xxl-0" style="width:32px; height:32px"><span class="text-success-dark" data-feather="dollar-sign" style="width:24px; height:24px"></span></div>
|
||||
<div class="d-flex bg-primary-subtle rounded flex-center me-3 mb-sm-3 mb-md-0 mb-xl-3 mb-xxl-0" style="width:32px; height:32px"><span class="text-primary-dark" data-feather="layout" style="width:24px; height:24px"></span></div>
|
||||
<div>
|
||||
<p class="fw-bold mb-1">{{ _("Quotation Amount") }}</p>
|
||||
<h4 class="fw-bolder text-nowrap">
|
||||
@ -123,19 +165,19 @@
|
||||
</div>
|
||||
<div class="col-sm-auto">
|
||||
<div class="d-sm-block d-inline-flex d-md-flex flex-xl-column flex-xxl-row align-items-center align-items-xl-start align-items-xxl-center border-start-sm ps-sm-5 border-translucent">
|
||||
<div class="d-flex bg-info-subtle rounded flex-center me-3 mb-sm-3 mb-md-0 mb-xl-3 mb-xxl-0" style="width:32px; height:32px"><span class="text-info-dark" data-feather="code" style="width:24px; height:24px"></span></div>
|
||||
<div class="d-flex bg-success-subtle rounded flex-center me-3 mb-sm-3 mb-md-0 mb-xl-3 mb-xxl-0" style="width:32px; height:32px"><span class="text-success-dark currency" style="width:40px; height:24px">{{CURRENCY}}</span></div>
|
||||
<div>
|
||||
<p class="fw-bold mb-1">Code</p>
|
||||
<h4 class="fw-bolder text-nowrap">PHO1234</h4>
|
||||
<p class="fw-bold mb-1">{{ _("Amount") }}</p>
|
||||
<h4 class="fw-bolder text-nowrap">{{opportunity.amount}}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-auto">
|
||||
<div class="d-sm-block d-inline-flex d-md-flex flex-xl-column flex-xxl-row align-items-center align-items-xl-start align-items-xxl-center border-start-sm ps-sm-5 border-translucent">
|
||||
<div class="d-flex bg-primary-subtle rounded flex-center me-3 mb-sm-3 mb-md-0 mb-xl-3 mb-xxl-0" style="width:32px; height:32px"><span class="text-primary-dark" data-feather="layout" style="width:24px; height:24px"></span></div>
|
||||
<div class="d-flex bg-success-subtle rounded flex-center me-3 mb-sm-3 mb-md-0 mb-xl-3 mb-xxl-0" style="width:32px; height:32px"><span class="text-success-dark currency" style="width:40px; height:24px">{{CURRENCY}}</span></div>
|
||||
<div>
|
||||
<p class="fw-bold mb-1">Type</p>
|
||||
<h4 class="fw-bolder text-nowrap">New Business</h4>
|
||||
<p class="fw-bold mb-1">{{ _("Expected Revenue") }}</p>
|
||||
<h4 class="fw-bolder text-nowrap">{{opportunity.expected_revenue}}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -160,19 +202,19 @@
|
||||
</td>
|
||||
<td class="py-2 d-none d-sm-block pe-sm-2">:</td>
|
||||
<td class="py-2">
|
||||
<p class="ps-6 ps-sm-0 fw-semibold mb-0 mb-0 pb-3 pb-sm-0">{{ opportunity.probability }}</p>
|
||||
<p class="ps-6 ps-sm-0 fw-semibold mb-0 mb-0 pb-3 pb-sm-0">{{ opportunity.probability }} (%)</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="d-flex bg-info-subtle rounded-circle flex-center me-3" style="width:24px; height:24px"><span class="text-info-dark" data-feather="trending-up" style="width:16px; height:16px"></span></div>
|
||||
<div class="d-flex bg-success-subtle rounded flex-center me-3 mb-sm-3 mb-md-0 mb-xl-3 mb-xxl-0" style="width:32px; height:32px"><span class="text-info-dark currency" style="width:24px; height:24px">{{CURRENCY}}</span></div>
|
||||
<p class="fw-bold mb-0">{{ _("Estimated Revenue") }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 d-none d-sm-block pe-sm-2">:</td>
|
||||
<td class="py-2">
|
||||
<p class="ps-6 ps-sm-0 fw-semibold mb-0"><span class="currency">{{CURRENCY}}</span>{{ opportunity.expected_revenue }}</p>
|
||||
<p class="ps-6 ps-sm-0 fw-semibold mb-0"><span class="currency pe-1">{{CURRENCY}}</span>{{ opportunity.expected_revenue }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@ -192,7 +234,7 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 d-none d-sm-block pe-sm-2">:</td>
|
||||
<td class="py-2"><a class="ps-6 ps-sm-0 fw-semibold mb-0 pb-3 pb-sm-0 text-body" href="tel:{{ opportunity.customer.phone_number }}">{{ opportunity.customer.phone }}</a></td>
|
||||
<td class="py-2"><a class="ps-6 ps-sm-0 fw-semibold mb-0 pb-3 pb-sm-0 text-body" href="tel:{{ opportunity.customer.phone_number }}">{{ opportunity.customer.phone_number }}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2">
|
||||
@ -222,19 +264,27 @@
|
||||
</td>
|
||||
<td class="py-2 d-none d-sm-block pe-sm-2">:</td>
|
||||
<td class="py-2">
|
||||
<div class="ps-6 ps-sm-0 fw-semibold mb-0 pb-3 pb-sm-0">{{ opportunity.customer.get_full_name}}</div>
|
||||
{% if opportunity.customer %}
|
||||
<div class="ps-6 ps-sm-0 fw-semibold mb-0 pb-3 pb-sm-0">{{ opportunity.customer.full_name}}</div>
|
||||
{% else %}
|
||||
<div class="ps-6 ps-sm-0 fw-semibold mb-0 pb-3 pb-sm-0">{{ opportunity.organization}}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="d-flex bg-info-subtle rounded-circle flex-center me-3" style="width:24px; height:24px"><span class="text-info-dark" data-feather="edit" style="width:16px; height:16px"></span></div>
|
||||
<p class="fw-bold mb-0">{{ _("Staff") }}</p>
|
||||
<p class="fw-bold mb-0">{{ _("Assigned To") }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 d-none d-sm-block pe-sm-2">:</td>
|
||||
<td class="py-2">
|
||||
<div class="ps-6 ps-sm-0 fw-semibold mb-0">{{ opportunity.staff.get_local_name}}</div>
|
||||
{% if request.user.email == opportunity.staff.email %}
|
||||
<div class="ps-6 ps-sm-0 fw-semibold mb-0">You</div>
|
||||
{% else %}
|
||||
<div class="ps-6 ps-sm-0 fw-semibold mb-0">{{ opportunity.staff.get_local_name}}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@ -255,19 +305,19 @@
|
||||
</td>
|
||||
<td class="py-2 d-none d-sm-block pe-sm-2">:</td>
|
||||
<td class="py-2">
|
||||
<div class="ps-6 ps-sm-0 fw-semibold mb-0 pb-3 pb-sm-0">{{ opportunity.created|date}}</div>
|
||||
<div class="ps-6 ps-sm-0 fw-semibold mb-0 pb-3 pb-sm-0">{{ opportunity.created|naturaltime|capfirst}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="d-flex bg-warning-subtle rounded-circle flex-center me-3" style="width:24px; height:24px"><span class="text-warning-dark" data-feather="clock" style="width:16px; height:16px"></span></div>
|
||||
<p class="fw-bold mb-0">{{ _("Closing Date")}}</p>
|
||||
<p class="fw-bold mb-0">{{ _("Expected Closing Date")}}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 d-none d-sm-block pe-sm-2">:</td>
|
||||
<td class="py-2">
|
||||
<div class="ps-6 ps-sm-0 fw-semibold mb-0">{{ opportunity.closing_date|date}}</div>
|
||||
<div class="ps-6 ps-sm-0 fw-semibold mb-0">{{ opportunity.expected_close_date|date}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@ -275,12 +325,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<ul class="nav nav-underline fs-9 deal-details scrollbar flex-nowrap w-100 pb-1 mb-6" id="myTab" role="tablist" style="overflow-y: hidden;">
|
||||
<li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link active" id="activity-tab" data-bs-toggle="tab" href="#tab-activity" role="tab" aria-controls="tab-activity" aria-selected="false" tabindex="-1"> <span class="fa-solid fa-chart-line me-2 tab-icon-color"></span>Activity</a></li>
|
||||
<li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link" id="notes-tab" data-bs-toggle="tab" href="#tab-notes" role="tab" aria-controls="tab-notes" aria-selected="false" tabindex="-1"> <span class="fa-solid fa-clipboard me-2 tab-icon-color"></span>Notes</a></li>
|
||||
<li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link" id="meeting-tab" data-bs-toggle="tab" href="#tab-meeting" role="tab" aria-controls="tab-meeting" aria-selected="true"> <span class="fa-solid fa-video me-2 tab-icon-color"></span>Meeting</a></li>
|
||||
<li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link active" id="activity-tab" data-bs-toggle="tab" href="#tab-activity" role="tab" aria-controls="tab-activity" aria-selected="false" tabindex="-1"> <span class="fa-solid fa-chart-line me-2 tab-icon-color"></span>{{ _("Activity") }}</a></li>
|
||||
<li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link" id="notes-tab" data-bs-toggle="tab" href="#tab-notes" role="tab" aria-controls="tab-notes" aria-selected="false" tabindex="-1"> <span class="fa-solid fa-clipboard me-2 tab-icon-color"></span>{{ _("Notes") }}</a></li>
|
||||
<li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link" id="meeting-tab" data-bs-toggle="tab" href="#tab-meeting" role="tab" aria-controls="tab-meeting" aria-selected="true"> <span class="fa-solid fa-video me-2 tab-icon-color"></span>{{ _("Meetings") }}</a></li>
|
||||
{% comment %} <li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link" id="task-tab" data-bs-toggle="tab" href="#tab-task" role="tab" aria-controls="tab-task" aria-selected="true"> <span class="fa-solid fa-square-check me-2 tab-icon-color"></span>Task</a></li> {% endcomment %}
|
||||
<li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link" id="call-tab" data-bs-toggle="tab" href="#tab-call" role="tab" aria-controls="tab-call" aria-selected="true"> <span class="fa-solid fa-phone me-2 tab-icon-color"></span>Call</a></li>
|
||||
<li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link" id="emails-tab" data-bs-toggle="tab" href="#tab-emails" role="tab" aria-controls="tab-emails" aria-selected="true"> <span class="fa-solid fa-envelope me-2 tab-icon-color"></span>Emails </a></li>
|
||||
<li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link" id="call-tab" data-bs-toggle="tab" href="#tab-call" role="tab" aria-controls="tab-call" aria-selected="true"> <span class="fa-solid fa-phone me-2 tab-icon-color"></span>{{ _("Calls") }}</a></li>
|
||||
<li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link" id="emails-tab" data-bs-toggle="tab" href="#tab-emails" role="tab" aria-controls="tab-emails" aria-selected="true"> <span class="fa-solid fa-envelope me-2 tab-icon-color"></span>{{ _("Emails")}} </a></li>
|
||||
<li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link" id="tasks-tab" data-bs-toggle="tab" href="#tab-tasks" role="tab" aria-controls="tab-tasks" aria-selected="true"> <span class="fa-solid fa-envelope me-2 tab-icon-color fs-8"></span>{{ _("Tasks") }}</a></li>
|
||||
|
||||
{% comment %} <li class="nav-item text-nowrap me-2" role="presentation"><a class="nav-link" id="attachments-tab" data-bs-toggle="tab" href="#tab-attachments" role="tab" aria-controls="tab-attachments" aria-selected="true"> <span class="fa-solid fa-paperclip me-2 tab-icon-color"></span>Attachments</a></li> {% endcomment %}
|
||||
</ul>
|
||||
<div class="tab-content" id="myTabContent">
|
||||
@ -300,7 +352,7 @@
|
||||
<button class="btn btn-phoenix-primary btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#activityModal"><span class="fas fa-plus me-1"></span>{{ _("Add Activity") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% for activity in activities %}
|
||||
{% for activity in opportunity.get_activities %}
|
||||
<div class="border-bottom border-translucent py-4">
|
||||
<div class="d-flex">
|
||||
<div class="d-flex bg-primary-subtle rounded-circle flex-center me-3 bg-primary-subtle" style="width:25px; height:25px">
|
||||
@ -318,7 +370,17 @@
|
||||
<div class="d-flex justify-content-between flex-column flex-xl-row mb-2 mb-sm-0">
|
||||
<div class="flex-1 me-2">
|
||||
<h5 class="text-body-highlight lh-sm"></h5>
|
||||
<p class="fs-9 mb-0">by<a class="ms-1" href="#!">{{activity.created_by}}</a></p>
|
||||
<p class="fs-9 mb-0">{{activity.notes}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between flex-column flex-xl-row mb-2 mb-sm-0">
|
||||
<div class="flex-1 me-2">
|
||||
<h5 class="text-body-highlight lh-sm"></h5>
|
||||
{% if request.user.email == activity.created_by %}
|
||||
<p class="fs-9 mb-0">by <a class="ms-1" href="#!">You</a></p>
|
||||
{% else %}
|
||||
<p class="fs-9 mb-0">by<a class="ms-1" href="#!">{{activity.created_by}}</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="fs-9"><span class="fa-regular fa-calendar-days text-primary me-2"></span><span class="fw-semibold">{{activity.created|naturalday|capfirst}}</span></div>
|
||||
</div>
|
||||
@ -337,11 +399,11 @@
|
||||
</form>
|
||||
<div class="row gy-4 note-list">
|
||||
<div class="col-12 col-xl-auto flex-1">
|
||||
{% for note in notes %}
|
||||
{% for note in opportunity.get_notes %}
|
||||
<div class="border-2 border-dashed mb-4 pb-4 border-bottom border-translucent">
|
||||
<p class="mb-1 text-body-highlight">{{ note.note }}</p>
|
||||
<div class="d-flex">
|
||||
<div class="fs-9 text-body-tertiary text-opacity-85"><span class="fa-solid fa-clock me-2"></span><span class="fw-semibold me-1">{{note.created}}</span></div>
|
||||
<div class="fs-9 text-body-tertiary text-opacity-85"><span class="fa-solid fa-clock me-2"></span><span class="fw-semibold me-1">{{note.created|naturaltime|capfirst}}</span></div>
|
||||
<p class="fs-9 mb-0 text-body-tertiary text-opacity-85">by<a class="ms-1 fw-semibold" href="#!">{{note.created_by}}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
@ -397,6 +459,7 @@
|
||||
<a href="{% url 'schedule_lead' opportunity.lead.slug %}" class="btn btn-primary"><span class="fa-solid fa-plus me-2"></span>Add Call</a>
|
||||
</div>
|
||||
</div>
|
||||
<pre>{{opportunity.get_all_notes}}</pre>
|
||||
<div class="border-top border-bottom border-translucent" id="leadDetailsTable" data-list='{"valueNames":["name","description","create_date","create_by","last_activity"],"page":5,"pagination":true}'>
|
||||
<div class="table-responsive scrollbar mx-n1 px-1">
|
||||
<table class="table fs-9 mb-0">
|
||||
@ -412,7 +475,7 @@
|
||||
<tr class="hover-actions-trigger btn-reveal-trigger position-static">
|
||||
<td class="description align-middle white-space-nowrap text-start fw-bold text-body-tertiary py-2 pe-6">{{call.purpose}}</td>
|
||||
<td class="create_date text-end align-middle white-space-nowrap text-body py-2">{{call.scheduled_by}}</td>
|
||||
<td class="create_by align-middle white-space-nowrap fw-semibold text-body-highlight">{{call.created_at}}</td>
|
||||
<td class="create_by align-middle white-space-nowrap fw-semibold text-body-highlight">{{call.created_at|naturaltime|capfirst}}</td>
|
||||
<td class="align-middle text-end white-space-nowrap pe-0 action py-2">
|
||||
<div class="btn-reveal-trigger position-static">
|
||||
<button class="btn btn-sm dropdown-toggle dropdown-caret-none transition-none btn-reveal" type="button" data-bs-toggle="dropdown" data-boundary="window" aria-haspopup="true" aria-expanded="false" data-bs-reference="parent"><span class="fas fa-ellipsis-h fs-10"></span></button>
|
||||
@ -440,6 +503,14 @@
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-emails" role="tabpanel" aria-labelledby="emails-tab">
|
||||
<h2 class="mb-4">Emails</h2>
|
||||
<div class="d-flex justify-content-end">
|
||||
<a href="{% url 'send_lead_email' opportunity.lead.slug %}">
|
||||
<button type="button" class="btn btn-sm btn-phoenix-primary">
|
||||
<span class="fas fa-plus me-1"></span>
|
||||
{% trans 'Send Email' %}
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<div class="scrollbar">
|
||||
<ul class="nav nav-underline fs-9 flex-nowrap mb-1" id="emailTab" role="tablist">
|
||||
@ -450,7 +521,6 @@
|
||||
<form class="position-relative">
|
||||
<input class="form-control search-input search" type="search" placeholder="Search..." aria-label="Search" />
|
||||
<span class="fas fa-search search-box-icon"></span>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<div class="tab-content" id="profileTabContent">
|
||||
@ -507,6 +577,50 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-tasks" role="tabpanel" aria-labelledby="tasks-tab">
|
||||
<div class="mb-1 d-flex justify-content-between align-items-center">
|
||||
<h3 class="mb-0" id="scrollspyEmails">{{ _("Tasks") }}</h3>
|
||||
<button class="btn btn-phoenix-primary btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#taskModal"><span class="fas fa-plus me-1"></span>{{ _("Add Task") }}</button>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<div class="border-top border-bottom border-translucent" id="allEmailsTable" data-list='{"valueNames":["subject","sent","date","source","status"],"page":7,"pagination":true}'>
|
||||
<div class="table-responsive scrollbar mx-n1 px-1">
|
||||
<table class="table fs-9 mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="white-space-nowrap fs-9 align-middle ps-0" style="width:26px;">
|
||||
<div class="form-check mb-0 fs-8">
|
||||
<input class="form-check-input" type="checkbox" data-bulk-select='{"body":"all-email-table-body"}' />
|
||||
</div>
|
||||
</th>
|
||||
<th class="sort white-space-nowrap align-middle pe-3 ps-0 text-uppercase" scope="col" data-sort="subject" style="width:31%; min-width:350px">Title</th>
|
||||
<th class="sort align-middle pe-3 text-uppercase" scope="col" data-sort="sent" style="width:15%; min-width:130px">Assigned to</th>
|
||||
<th class="sort align-middle text-start text-uppercase" scope="col" data-sort="date" style="min-width:165px">Due Date</th>
|
||||
<th class="sort align-middle text-start text-uppercase" scope="col" data-sort="date" style="min-width:165px">Completed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="list" id="all-tasks-table-body">
|
||||
{% for task in opportunity.get_tasks %}
|
||||
{% include "partials/task.html" %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="row align-items-center justify-content-between py-2 pe-0 fs-9">
|
||||
<div class="col-auto d-flex">
|
||||
<p class="mb-0 d-none d-sm-block me-3 fw-semibold text-body" data-list-info="data-list-info"></p><a class="fw-semibold" href="" data-list-view="*">View all<span class="fas fa-angle-right ms-1" data-fa-transform="down-1"></span></a><a class="fw-semibold d-none" href="" data-list-view="less">View Less<span class="fas fa-angle-right ms-1" data-fa-transform="down-1"></span></a>
|
||||
</div>
|
||||
<div class="col-auto d-flex">
|
||||
<button class="page-link" data-list-pagination="prev"><span class="fas fa-chevron-left"></span></button>
|
||||
<ul class="mb-0 pagination"></ul>
|
||||
<button class="page-link pe-0" data-list-pagination="next"><span class="fas fa-chevron-right"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-attachments" role="tabpanel" aria-labelledby="attachments-tab">
|
||||
<h2 class="mb-3">Attachments</h2>
|
||||
<div class="border-top border-dashed pt-3 pb-4">
|
||||
@ -554,4 +668,5 @@
|
||||
</div>
|
||||
</div>
|
||||
{% include "components/activity_modal.html" with content_type="opportunity" slug=opportunity.slug %}
|
||||
{% include "components/task_modal.html" with content_type="opportunity" slug=opportunity.slug %}
|
||||
{% endblock %}
|
||||
@ -81,6 +81,22 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Amount Field -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="{{ form.amount.id_for_label }}">
|
||||
{{ form.amount.label }}
|
||||
<span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><span class="currency">{{CURRENCY}}</span></span>
|
||||
{{ form.amount|add_class:"form-control" }}
|
||||
</div>
|
||||
{% if form.amount.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.amount.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Probability Field -->
|
||||
<div class="mb-4">
|
||||
@ -107,7 +123,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Expected Revenue -->
|
||||
<!-- Expected Revenue -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="{{ form.expected_revenue.id_for_label }}">
|
||||
{{ form.expected_revenue.label }}
|
||||
@ -123,18 +139,19 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Closing Date -->
|
||||
<div class="mb-5">
|
||||
<label class="form-label" for="{{ form.closing_date.id_for_label }}">
|
||||
{{ form.closing_date.label }}
|
||||
</label>
|
||||
<div class="input-group">
|
||||
{{ form.closing_date|add_class:"form-control" }}
|
||||
{{ form.expected_close_date|add_class:"form-control" }}
|
||||
<span class="input-group-text"><span class="far fa-calendar"></span></span>
|
||||
</div>
|
||||
{% if form.closing_date.errors %}
|
||||
{% if form.expected_close_date.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.closing_date.errors }}
|
||||
{{ form.expected_close_date.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -188,6 +205,10 @@
|
||||
|
||||
<script>
|
||||
function updateProbabilityValue(value) {
|
||||
const amount = document.getElementById('id_amount');
|
||||
const expectedRevenue = document.getElementById('id_expected_revenue');
|
||||
expectedRevenue.value = (parseFloat(amount.value) * value / 100).toFixed(2);
|
||||
|
||||
const badge = document.getElementById('probability-value');
|
||||
badge.textContent = value + '%';
|
||||
|
||||
|
||||
@ -19,21 +19,37 @@
|
||||
{% if opportunity.get_stage_display == 'Closed Won' %}bg-success-soft
|
||||
{% elif opportunity.get_stage_display == 'Closed Lost' %}bg-danger-soft{% endif %}">
|
||||
<div class="card-body">
|
||||
<h5 class="mb-4">Opportunity for {{ opportunity.customer.customer_name }}</h5>
|
||||
<div class="avatar avatar-xl me-3 mb-3">
|
||||
{% if opportunity.car.id_car_make.logo %}
|
||||
<img class="rounded" src="{{ opportunity.car.id_car_make.logo.url }}" alt="" />
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if opportunity.customer %}
|
||||
<h5 class="mb-4">Opportunity for {{ opportunity.customer }}</h5>
|
||||
{% elif opportunity.organization %}
|
||||
<h5 class="mb-4">Opportunity for {{ opportunity.organization }}</h5>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div class="d-flex gap-2">
|
||||
{% if opportunity.get_stage_display == "Negotiation" %}
|
||||
<span class="badge badge-phoenix fs-10 badge-phoenix-primary">{{ opportunity.get_stage_display }}</span>
|
||||
{% elif opportunity.get_stage_display == "Discovery" %}
|
||||
<span class="badge badge-phoenix fs-10 badge-phoenix-info">{{ opportunity.get_stage_display }}</span>
|
||||
{% elif opportunity.get_stage_display == "Proposal" %}
|
||||
<span class="badge badge-phoenix fs-10 badge-phoenix-warning">{{ opportunity.get_stage_display }}</span>
|
||||
{% elif opportunity.get_stage_display == "Closed Won" %}
|
||||
<span class="badge badge-phoenix fs-10 badge-phoenix-success">{{ opportunity.get_stage_display }}</span>
|
||||
{% elif opportunity.get_stage_display == "Closed Lost" %}
|
||||
<span class="badge badge-phoenix fs-10 badge-phoenix-danger">{{ opportunity.get_stage_display }}</span>
|
||||
{% if opportunity.stage == "qualification" %}
|
||||
<span class="badge badge-phoenix fs-10 badge-phoenix-primary">
|
||||
{% elif opportunity.stage == "test_drive" %}
|
||||
<span class="badge badge-phoenix fs-10 badge-phoenix-info">
|
||||
{% elif opportunity.stage == "quotation" %}
|
||||
<span class="badge badge-phoenix fs-10 badge-phoenix-warning">
|
||||
{% elif opportunity.stage == "negotiation" %}
|
||||
<span class="badge badge-phoenix fs-10 badge-phoenix-warning">
|
||||
{% elif opportunity.stage == "financing" %}
|
||||
<span class="badge badge-phoenix fs-10 badge-phoenix-warning">
|
||||
{% elif opportunity.stage == "closed_won" %}
|
||||
<span class="badge badge-phoenix fs-10 badge-phoenix-success">
|
||||
{% elif opportunity.stage == "closed_lost" %}
|
||||
<span class="badge badge-phoenix fs-10 badge-phoenix-danger">
|
||||
{% elif opportunity.stage == "on_hold" %}
|
||||
<span class="badge badge-phoenix fs-10 badge-phoenix-secondary">
|
||||
{% endif %}
|
||||
{{ opportunity.stage }}</span>
|
||||
<span class="badge badge-phoenix fs-10
|
||||
{% if opportunity.get_stage_display == 'Won' %}badge-phoenix-success
|
||||
{% elif opportunity.get_stage_display == 'Lost' %}badge-phoenix-danger{% endif %}">
|
||||
@ -49,7 +65,12 @@
|
||||
<div class="deals-company-agent d-flex justify-content-between mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="uil uil-user me-2"></span>
|
||||
<p class="text-body-secondary fw-bold fs-10 mb-0">{{ opportunity.staff.name }}</p>
|
||||
<p class="text-body-secondary fw-bold fs-10 mb-0">
|
||||
{{ _("Assigned To") }}{% if request.user.email == opportunity.staff.email %}
|
||||
{{ _("You") }}
|
||||
{% else %}
|
||||
{{ opportunity.staff.name }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="mb-3 w-100">
|
||||
@ -73,7 +94,9 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<p class="fw-semibold fs-9 mb-0 text-body-emphasis">{{ opportunity.closing_date|naturalday|capfirst }}</p>
|
||||
{% if opportunity.expected_close_date %}
|
||||
<p class="fw-semibold fs-9 mb-0 text-body-emphasis">{{ opportunity.expected_close_date|naturalday|capfirst }}</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
BIN
templates/haikalbot/.DS_Store
vendored
Normal file
BIN
templates/haikalbot/.DS_Store
vendored
Normal file
Binary file not shown.
188
templates/haikalbot/chat.html
Normal file
188
templates/haikalbot/chat.html
Normal file
@ -0,0 +1,188 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block title %}Haikal Bot{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.3.2/papaparse.min.js"></script>
|
||||
|
||||
<div class="container mt-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-robot me-2"></i>{% trans "HaikalBot" %}</h5>
|
||||
<div>
|
||||
<button id="export-btn" class="btn btn-sm btn-outline-secondary" style="display:none;">
|
||||
{% trans "Export CSV" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" style="max-height: 60vh; overflow-y: auto;" id="chat-history"></div>
|
||||
<div class="card-footer bg-white border-top">
|
||||
<form id="chat-form" class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="btn btn-light" id="mic-btn"><i class="fas fa-microphone"></i></button>
|
||||
<input type="text" class="form-control" id="chat-input" placeholder="{% trans 'Type your question...' %}" required />
|
||||
<button type="submit" class="btn btn-primary"><i class="fas fa-paper-plane"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="chart-container" style="display:none;" class="p-4 border-top">
|
||||
<canvas id="chart-canvas" height="200px"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const chatHistory = document.getElementById('chat-history');
|
||||
const chartContainer = document.getElementById('chart-container');
|
||||
const chartCanvas = document.getElementById('chart-canvas');
|
||||
const exportBtn = document.getElementById('export-btn');
|
||||
let chartInstance = null;
|
||||
let latestDataTable = null;
|
||||
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== "") {
|
||||
const cookies = document.cookie.split(";");
|
||||
for (let cookie of cookies) {
|
||||
cookie = cookie.trim();
|
||||
if (cookie.substring(0, name.length + 1) === name + "=") {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
function speak(text) {
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = document.documentElement.lang || "en";
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}
|
||||
|
||||
function renderTable(data) {
|
||||
latestDataTable = data;
|
||||
exportBtn.style.display = 'inline-block';
|
||||
const headers = Object.keys(data[0]);
|
||||
let html = '<div class="table-responsive"><table class="table table-bordered table-striped"><thead><tr>';
|
||||
headers.forEach(h => html += `<th>${h}</th>`);
|
||||
html += '</tr></thead><tbody>';
|
||||
data.forEach(row => {
|
||||
html += '<tr>' + headers.map(h => `<td>${row[h]}</td>`).join('') + '</tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function appendMessage(role, htmlContent) {
|
||||
const align = role === 'AI' ? 'bg-secondary-light' : 'bg-primary-light';
|
||||
chatHistory.innerHTML += `
|
||||
<div class="mb-3 p-3 rounded ${align}">
|
||||
<strong>${role}:</strong><br>${htmlContent}
|
||||
</div>
|
||||
`;
|
||||
chatHistory.scrollTop = chatHistory.scrollHeight;
|
||||
}
|
||||
|
||||
document.getElementById('chat-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const input = document.getElementById('chat-input');
|
||||
const prompt = input.value.trim();
|
||||
const csrfToken = getCookie("csrftoken");
|
||||
|
||||
if (!prompt) return;
|
||||
|
||||
appendMessage('You', prompt);
|
||||
input.value = "";
|
||||
chartContainer.style.display = 'none';
|
||||
exportBtn.style.display = 'none';
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
chartInstance = null;
|
||||
}
|
||||
|
||||
const response = await fetch("{% url 'haikalbot' %}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-CSRFToken": csrfToken
|
||||
},
|
||||
body: new URLSearchParams({ prompt })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Show chart if available
|
||||
if (result.chart && result.chart.type && result.chart.labels && result.chart.data) {
|
||||
chartInstance = new Chart(chartCanvas, {
|
||||
type: result.chart.type,
|
||||
data: {
|
||||
labels: result.chart.labels,
|
||||
datasets: [{
|
||||
label: result.chart.labels.join(", "),
|
||||
data: result.chart.data,
|
||||
backgroundColor: result.chart.backgroundColor || []
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: result.chart.type.toUpperCase()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
chartContainer.style.display = 'block';
|
||||
appendMessage('AI', `{% trans "Chart displayed below." %}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Table if list of objects
|
||||
if (Array.isArray(result.data) && result.data.length && typeof result.data[0] === 'object') {
|
||||
const tableHTML = renderTable(result.data);
|
||||
appendMessage('AI', tableHTML);
|
||||
|
||||
} else {
|
||||
const content = typeof result.data === 'object'
|
||||
? `<pre>${JSON.stringify(result.data, null, 2)}</pre>`
|
||||
: `<p>${result.data}</p>`;
|
||||
appendMessage('AI', content);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('export-btn').addEventListener('click', () => {
|
||||
if (!latestDataTable) return;
|
||||
const csv = Papa.unparse(latestDataTable);
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'haikal_data.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
|
||||
// Voice input (speech-to-text)
|
||||
document.getElementById('mic-btn').addEventListener('click', () => {
|
||||
const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
|
||||
recognition.lang = document.documentElement.lang || "en";
|
||||
recognition.interimResults = false;
|
||||
recognition.maxAlternatives = 1;
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
const speech = event.results[0][0].transcript;
|
||||
document.getElementById('chat-input').value = speech;
|
||||
};
|
||||
|
||||
recognition.onerror = (e) => {
|
||||
console.error('Speech recognition error', e);
|
||||
};
|
||||
|
||||
recognition.start();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -500,7 +500,6 @@ $(document).ready(function() {
|
||||
return isArabic ? (obj[arabicKey] || obj[englishKey] || '') : (obj[englishKey] || obj[arabicKey] || '');
|
||||
}
|
||||
|
||||
// Copy message functionality
|
||||
$(document).on('click', '.copy-btn', function() {
|
||||
const text = $(this).closest('.d-flex').find('.chat-message').text().trim();
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
@ -516,7 +515,6 @@ $(document).ready(function() {
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// Initialize
|
||||
scrollToBottom();
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if perms.inventory.view_lead %}
|
||||
{% if perms.inventory.view_lead or perms.django_ledger.view_invoicemodel %}
|
||||
<div class="nav-item-wrapper">
|
||||
<a class="nav-link dropdown-indicator label-1" href="#nv-sales" role="button" data-bs-toggle="collapse" aria-expanded="false" aria-controls="nv-sales">
|
||||
<div class="d-flex align-items-center">
|
||||
@ -268,7 +268,7 @@
|
||||
<span class="nav-link-icon"><i class="fa-solid fa-book-open"></i></span><span class="nav-link-text">{% trans 'Reports' %}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% if request.user.dealer.entity %}
|
||||
|
||||
{% if perms.django_ledger.view_accountmodel %}
|
||||
<div class="parent-wrapper label-1">
|
||||
<ul class="nav collapse parent" data-bs-parent="#navbarVerticalCollapse" id="nv-reports">
|
||||
@ -323,7 +323,7 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -416,7 +416,7 @@
|
||||
<div class="overflow-auto scrollbar" style="height: 10rem;">
|
||||
<ul class="nav d-flex flex-column mb-2 pb-1">
|
||||
{% if request.is_dealer %}
|
||||
<li class="nav-item">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link px-3 d-block" href="{% url 'dealer_detail' request.user.dealer.slug %}"> <span class="me-2 text-body align-bottom" data-feather="user"></span><span>{% translate 'profile'|capfirst %}</span></a>
|
||||
</li>
|
||||
{% else %}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{%block title%} {%trans 'Add Colors'%} {% endblock%}
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
<h5 class="text-center">{% trans "Add Colors" %}</h5>
|
||||
@ -18,7 +19,8 @@
|
||||
<input class="color-radio"
|
||||
type="radio"
|
||||
name="exterior"
|
||||
value="{{ color.id }}">
|
||||
value="{{ color.id }}" {% if color.id == form.instance.exterior.id %}checked{% endif %}>
|
||||
|
||||
<div class="card-body color-display"
|
||||
style="background-color: rgb({{ color.rgb }})">
|
||||
<div class="">
|
||||
@ -38,7 +40,7 @@
|
||||
<input class="color-radio"
|
||||
type="radio"
|
||||
name="interior"
|
||||
value="{{ color.id }}">
|
||||
value="{{ color.id }}" {% if color.id == form.instance.interior.id %}checked{% endif %}>
|
||||
<div class="card-body color-display"
|
||||
style="background-color: rgb({{ color.rgb }})">
|
||||
<div class="">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{% extends 'base.html' %}
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n static custom_filters %}
|
||||
{% block title %}{{ _("Car Details") }}{% endblock %}
|
||||
{% block customCSS %}
|
||||
@ -17,14 +17,24 @@
|
||||
{% endblock customCSS %}
|
||||
|
||||
{% block content %}
|
||||
{% if not car.ready %}
|
||||
|
||||
{% if not car.ready and not car.status == 'sold' %}
|
||||
<div class="alert alert-outline-warning d-flex align-items-center"
|
||||
role="alert">
|
||||
<i class="fa-solid fa-circle-info fs-6"></i>
|
||||
{%if not car.finances and not car.colors%}
|
||||
<p class="mb-0 flex-1">
|
||||
{{ _("This car information is not complete , please add colors and finances before making it ready for sale .") }}<a class="ms-3 text-body-primary fs-9"
|
||||
href="{% url 'add_color' car.slug %}">{{ _("Add Color") }}</a>
|
||||
{{ _("This car information is not complete , please add colors and finances both before making it ready for sale .") }}
|
||||
</p>
|
||||
{% elif car.finances and not car.colors %}
|
||||
<p class="mb-0 flex-1">
|
||||
{{ _("This car information is not complete , please add colors before making it ready for sale .") }}
|
||||
</p>
|
||||
{%else%}
|
||||
<p class="mb-0 flex-1">
|
||||
{{ _("This car information is not complete , please add finances before making it ready for sale .") }}
|
||||
</p>
|
||||
{%endif%}
|
||||
<button class="btn-close"
|
||||
type="button"
|
||||
data-bs-dismiss="alert"
|
||||
@ -237,7 +247,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Discount Amount"|capfirst %}</th>
|
||||
<td>{{ car.finances.discount_amount|floatformat:2 }} -</td>
|
||||
<td>{{ car.finances.discount_amount|floatformat:2 }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Additional Fee"|capfirst %}</th>
|
||||
@ -285,6 +295,8 @@
|
||||
<div class="card-body">
|
||||
<div class="table-responsive scrollbar mb-3">
|
||||
<table class="table table-sm fs-9 mb-0 overflow-hidden">
|
||||
|
||||
<!--test-->
|
||||
{% if car.colors %}
|
||||
<tr>
|
||||
<th>{% trans 'Exterior' %}</th>
|
||||
@ -309,32 +321,27 @@
|
||||
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
{% comment %} {% if not car.get_transfer %}
|
||||
<a href="{% url 'car_finance_update' car.finances.pk %}"
|
||||
{% if not car.get_transfer %}
|
||||
<a href="{% url 'car_colors_update' car.slug %}"
|
||||
class="btn btn-phoenix-warning btn-sm mb-3">{% trans "Edit" %}</a>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">{% trans "Cannot Edit, Car in Transfer." %}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>{% trans "No finance details available." %}</p>
|
||||
{% if perms.inventory.add_carfinance %}
|
||||
<a href="{% url 'car_finance_create' car.slug %}"
|
||||
class="btn btn-phoenix-success btn-sm mb-3">{% trans "Add" %}</a>
|
||||
{% endif %} {% endcomment %}
|
||||
</td>
|
||||
</tr>
|
||||
{% comment %} <tr>
|
||||
<td colspan="2">{% trans "No colors available for this car." %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
{% if perms.inventory.change_carcolors %}
|
||||
<a href="{% url 'add_color' car.slug %}"
|
||||
class="btn btn-phoenix-success btn-sm">{% trans "Add" %}</a>
|
||||
|
||||
<td colspan="2">
|
||||
<p>{% trans "No color details available." %}</p>
|
||||
{% if perms.inventory.add_carcolors %}
|
||||
<a class="btn btn-phoenix-success btn-sm mb-3" href="{% url 'add_color' car.slug %}">{{ _("Add Color") }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr> {% endcomment %}
|
||||
</tr>
|
||||
{% endif %}
|
||||
<!--test-->
|
||||
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -179,10 +179,14 @@
|
||||
<a class="fw-bold" href="{% url 'car_detail' car.slug %}">{{ car.vin }}</a>
|
||||
</td>
|
||||
<td class="align-middle white-space-nowrap">
|
||||
{% if car.id_car_make %}
|
||||
<p class="text-body mb-0">{{ car.id_car_make.get_local_name|default:car.id_car_make.name }}</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle white-space-nowrap">
|
||||
{% if car.id_car_model %}
|
||||
<p class="text-body mb-0">{{ car.id_car_model.get_local_name|default:car.id_car_model.name }}</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle white-space-nowrap">
|
||||
<p class="text-body mb-0">{{ car.year }}</p>
|
||||
|
||||
@ -34,17 +34,13 @@
|
||||
{{ service.pk }}
|
||||
</td>
|
||||
<td class="align-middle product white-space-nowrap">
|
||||
{{ service.get_local_name }}
|
||||
{{ service.get_local_name|default:service.name }}
|
||||
</td>
|
||||
<td class="align-middle product white-space-nowrap">
|
||||
{{ service.uom }}
|
||||
{{ service.get_uom_display }}
|
||||
</td>
|
||||
<td class="align-middle product white-space-nowrap">
|
||||
{% if service.taxable %}
|
||||
Yes
|
||||
{% else %}
|
||||
No
|
||||
{% endif %}
|
||||
{{ service.taxable|yesno }}
|
||||
</td>
|
||||
<td class="align-middle product white-space-nowrap">
|
||||
{{ service.item.co }}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% load i18n custom_filters %}
|
||||
{% block title %}{% trans "Accounts" %}{% endblock title %}
|
||||
{% block accounts %}
|
||||
<a class="nav-link active fw-bold">
|
||||
@ -8,120 +8,137 @@
|
||||
</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<!--test-->
|
||||
<div class="row mt-4">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h3 class=""><i class="fa-solid fa-book"></i> {% trans "Accounts" %}</h3>
|
||||
<a href="{% url 'account_create' %}" class="btn btn-md btn-phoenix-primary"><i class="fa fa-plus me-2"></i>{% trans 'New Account' %}</a>
|
||||
</div>
|
||||
{% include "partials/search_box.html" %}
|
||||
|
||||
{% if page_obj.object_list %}
|
||||
<div class="table-responsive px-1 scrollbar mt-3">
|
||||
<table class="table align-items-center table-flush">
|
||||
<thead>
|
||||
<tr class="bg-body-highlight">
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{% trans "Type" %}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{% trans "Account Name" %}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{% trans "Code" %}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{% trans "Balance Type" %}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{% trans "Active" %}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="list">
|
||||
{% for account in accounts %}
|
||||
<!-- Account Type Tabs -->
|
||||
<div class="mb-4">
|
||||
<ul class="nav nav-tabs" id="accountTypeTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="assets-tab" data-bs-toggle="tab" data-bs-target="#assets" type="button" role="tab" aria-controls="assets" aria-selected="true">
|
||||
<i class="fas fa-wallet me-2"></i>{% trans "Assets" %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="cogs-tab" data-bs-toggle="tab" data-bs-target="#cogs" type="button" role="tab" aria-controls="cogs" aria-selected="false">
|
||||
<i class="fas fa-boxes me-2"></i>{% trans "COGS" %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="capital-tab" data-bs-toggle="tab" data-bs-target="#capital" type="button" role="tab" aria-controls="capital" aria-selected="false">
|
||||
<i class="fas fa-landmark me-2"></i>{% trans "Capital" %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="income-tab" data-bs-toggle="tab" data-bs-target="#income" type="button" role="tab" aria-controls="income" aria-selected="false">
|
||||
<i class="fas fa-money-bill-wave me-2"></i>{% trans "Income" %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="expenses-tab" data-bs-toggle="tab" data-bs-target="#expenses" type="button" role="tab" aria-controls="expenses" aria-selected="false">
|
||||
<i class="fas fa-receipt me-2"></i>{% trans "Expenses" %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="liabilities-tab" data-bs-toggle="tab" data-bs-target="#liabilities" type="button" role="tab" aria-controls="liabilities" aria-selected="false">
|
||||
<i class="fas fa-receipt me-2"></i>{% trans "Liabilities" %}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="modal fade" id="deleteModal"
|
||||
data-bs-backdrop="static"
|
||||
data-bs-keyboard="false"
|
||||
tabindex="-1"
|
||||
aria-labelledby="deleteModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteModalLabel">
|
||||
<div class="tab-content p-3 border border-top-0 rounded-bottom" id="accountTypeTabsContent">
|
||||
<!-- Assets Tab -->
|
||||
<div class="tab-pane fade show active" id="assets" role="tabpanel" aria-labelledby="assets-tab">
|
||||
{% include "partials/search_box.html" %}
|
||||
{% with accounts=accounts|filter_by_role:"ex_" %}
|
||||
{% include "ledger/coa_accounts/partials/account_table.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% trans "Delete Account" %}
|
||||
<span data-feather="alert-circle"></span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<p class="mb-0 text-danger fw-bold">
|
||||
{% trans "Are you sure you want to delete this Account?" %}
|
||||
</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-phoenix-secondary btn-sm" data-bs-dismiss="modal">
|
||||
{% trans "No" %}
|
||||
</button>
|
||||
<a type="button" class="btn btn-phoenix-danger btn-sm" href="{% url 'account_delete' account.uuid %}">
|
||||
{% trans "Yes" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- COGS Tab -->
|
||||
<div class="tab-pane fade" id="cogs" role="tabpanel" aria-labelledby="cogs-tab">
|
||||
{% include "partials/search_box.html" %}
|
||||
{% with accounts=accounts|filter_by_role:"cogs" %}
|
||||
{% include "ledger/coa_accounts/partials/account_table.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Capital Tab -->
|
||||
<div class="tab-pane fade" id="capital" role="tabpanel" aria-labelledby="capital-tab">
|
||||
{% include "partials/search_box.html" %}
|
||||
{% with accounts=accounts|filter_by_role:"eq_" %}
|
||||
{% include "ledger/coa_accounts/partials/account_table.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<tr class="hover-actions-trigger btn-reveal-trigger position-static">
|
||||
<td class="align-middle product white-space-nowrap">
|
||||
{{ account.role_bs|upper }}
|
||||
</td>
|
||||
<td class="align-middle product white-space-nowrap">
|
||||
{{ account.name }}
|
||||
</td>
|
||||
<td class="align-middle product white-space-nowrap">
|
||||
{{ account.code }}
|
||||
</td>
|
||||
<td class="align-middle product white-space-nowrap">
|
||||
<!-- Income Tab -->
|
||||
<div class="tab-pane fade" id="income" role="tabpanel" aria-labelledby="income-tab">
|
||||
{% include "partials/search_box.html" %}
|
||||
{% with accounts=accounts|filter_by_role:"in_" %}
|
||||
{% include "ledger/coa_accounts/partials/account_table.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% if account.balance_type == 'debit' %}
|
||||
<div class="badge badge-phoenix fs-10 badge-phoenix-success"><span class="fw-bold"><i class="fa-solid fa-circle-up"></i> {{ _("Debit") }}</span></div>
|
||||
{% else %}
|
||||
<div class="badge badge-phoenix fs-10 badge-phoenix-danger"><span class="fw-bold"><i class="fa-solid fa-circle-down"></i> {{ _("Credit") }}</span></div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle product white-space-nowrap">
|
||||
{% if account.active %}
|
||||
<span class="fw-bold text-success fas fa-check-circle"></span>
|
||||
{% else %}
|
||||
<span class="fw-bold text-danger far fa-times-circle"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle white-space-nowrap text-start">
|
||||
<div class="btn-reveal-trigger position-static">
|
||||
<button class="btn btn-sm dropdown-toggle dropdown-caret-none transition-none btn-reveal fs-10" type="button" data-bs-toggle="dropdown" data-boundary="window" aria-haspopup="true" aria-expanded="false" data-bs-reference="parent"><span class="fas fa-ellipsis-h fs-10"></span></button>
|
||||
<div class="dropdown-menu dropdown-menu-end py-2"><a href="{% url 'account_detail' account.uuid %}" class="dropdown-item text-success-dark">
|
||||
{% trans "View" %}
|
||||
</a>
|
||||
<div class="dropdown-divider"></div><button class="dropdown-item text-danger" data-bs-toggle="modal" data-bs-target="#deleteModal">{% trans "Delete" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted">{% trans "No Accounts Found" %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Expenses Tab -->
|
||||
<div class="tab-pane fade" id="expenses" role="tabpanel" aria-labelledby="expenses-tab">
|
||||
{% include "partials/search_box.html" %}
|
||||
{% with accounts=accounts|filter_by_role:"ex_" %}
|
||||
{% include "ledger/coa_accounts/partials/account_table.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<!-- Liabilities Tab -->
|
||||
<div class="tab-pane fade" id="liabilities" role="tabpanel" aria-labelledby="liabilities-tab">
|
||||
{% include "partials/search_box.html" %}
|
||||
{% with accounts=accounts|filter_by_role:"lia_" %}
|
||||
{% include "ledger/coa_accounts/partials/account_table.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<div class="d-flex">
|
||||
{% if is_paginated %}
|
||||
{% include 'partials/pagination.html' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
<!--test-->
|
||||
|
||||
<!-- Delete Modal (moved outside tables) -->
|
||||
<div class="modal fade" id="deleteModal" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteModalLabel">
|
||||
{% trans "Delete Account" %}
|
||||
<span data-feather="alert-circle"></span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<p class="mb-0 text-danger fw-bold">
|
||||
{% trans "Are you sure you want to delete this Account?" %}
|
||||
</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-phoenix-secondary btn-sm" data-bs-dismiss="modal">
|
||||
{% trans "No" %}
|
||||
</button>
|
||||
<a id="deleteAccountBtn" type="button" class="btn btn-phoenix-danger btn-sm" href="#">
|
||||
{% trans "Yes" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block customerJS %}
|
||||
<script>
|
||||
// Handle delete modal for all tables
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var deleteModal = document.getElementById('deleteModal');
|
||||
deleteModal.addEventListener('show.bs.modal', function(event) {
|
||||
var button = event.relatedTarget;
|
||||
var accountId = button.closest('tr').getAttribute('data-account-id');
|
||||
document.getElementById('deleteAccountBtn').href = `/accounts/delete/${accountId}/`;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
69
templates/ledger/coa_accounts/partials/account_table.html
Normal file
69
templates/ledger/coa_accounts/partials/account_table.html
Normal file
@ -0,0 +1,69 @@
|
||||
{% load i18n %}
|
||||
{% if accounts %}
|
||||
<div class="table-responsive px-1 scrollbar mt-3">
|
||||
<table class="table align-items-center table-flush">
|
||||
<thead>
|
||||
<tr class="bg-body-highlight">
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{% trans "Account Name" %}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{% trans "Code" %}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{% trans "Balance Type" %}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{% trans "Active" %}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="list">
|
||||
{% for account in accounts %}
|
||||
<tr class="hover-actions-trigger btn-reveal-trigger position-static" data-account-id="{{ account.uuid }}">
|
||||
<td class="align-middle product white-space-nowrap">
|
||||
{{ account.name }}
|
||||
</td>
|
||||
<td class="align-middle product white-space-nowrap">
|
||||
{{ account.code }}
|
||||
</td>
|
||||
<td class="align-middle product white-space-nowrap">
|
||||
{% if account.balance_type == 'debit' %}
|
||||
<div class="badge badge-phoenix fs-10 badge-phoenix-success"><span class="fw-bold"><i class="fa-solid fa-circle-up"></i> {{ _("Debit") }}</span></div>
|
||||
{% else %}
|
||||
<div class="badge badge-phoenix fs-10 badge-phoenix-danger"><span class="fw-bold"><i class="fa-solid fa-circle-down"></i> {{ _("Credit") }}</span></div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle product white-space-nowrap">
|
||||
{% if account.active %}
|
||||
<span class="fw-bold text-success fas fa-check-circle"></span>
|
||||
{% else %}
|
||||
<span class="fw-bold text-danger far fa-times-circle"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle white-space-nowrap text-start">
|
||||
<div class="btn-reveal-trigger position-static">
|
||||
<button class="btn btn-sm dropdown-toggle dropdown-caret-none transition-none btn-reveal fs-10" type="button" data-bs-toggle="dropdown" data-boundary="window" aria-haspopup="true" aria-expanded="false" data-bs-reference="parent"><span class="fas fa-ellipsis-h fs-10"></span></button>
|
||||
<div class="dropdown-menu dropdown-menu-end py-2">
|
||||
<a href="{% url 'account_detail' account.uuid %}" class="dropdown-item text-success-dark">
|
||||
{% trans "View" %}
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger" data-bs-toggle="modal" data-bs-target="#deleteModal">{% trans "Delete" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted">{% trans "No Accounts Found" %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<div class="d-flex">
|
||||
{% if is_paginated %}
|
||||
{% include 'partials/pagination.html' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert ">
|
||||
{% trans "No accounts found in this category." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
20
templates/partials/task.html
Normal file
20
templates/partials/task.html
Normal file
@ -0,0 +1,20 @@
|
||||
{% load static i18n humanize %}
|
||||
<tr id="task-{{task.pk}}" class="hover-actions-trigger btn-reveal-trigger position-static {% if task.completed %}completed-task{% endif %}">
|
||||
<td class="fs-9 align-middle px-0 py-3">
|
||||
<div class="form-check mb-0 fs-8">
|
||||
<input class="form-check-input" {% if task.completed %}checked{% endif %} type="checkbox" hx-post="{% url 'update_task' task.pk %}" hx-trigger="change" hx-swap="outerHTML" hx-target="#task-{{task.pk}}" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="subject order align-middle white-space-nowrap py-2 ps-0"><a class="fw-semibold text-primary" href="">{{task.title}}</a>
|
||||
<div class="fs-10 d-block">{{task.description}}</div>
|
||||
</td>
|
||||
<td class="sent align-middle white-space-nowrap text-start fw-bold text-body-tertiary py-2">{{task.assigned_to}}</td>
|
||||
<td class="date align-middle white-space-nowrap text-body py-2">{{task.created|naturalday|capfirst}}</td>
|
||||
<td class="date align-middle white-space-nowrap text-body py-2">
|
||||
{% if task.completed %}
|
||||
<span class="badge badge-phoenix fs-10 badge-phoenix-success"><i class="fa-solid fa-check"></i></span>
|
||||
{% else %}
|
||||
<span class="badge badge-phoenix fs-10 badge-phoenix-warning"><i class="fa-solid fa-xmark"></i></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
283
templates/sales/estimates/sale_order_form1.html
Normal file
283
templates/sales/estimates/sale_order_form1.html
Normal file
@ -0,0 +1,283 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n static %}
|
||||
{% load crispy_forms_filters %}
|
||||
{% block title %}
|
||||
{% trans 'Sale Order' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
/* Custom styling */
|
||||
.form-section {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-section-header {
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.required-field::after {
|
||||
content: " *";
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.search-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-select input {
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.search-select .search-icon {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.currency-input {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.currency-input .currency-symbol {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.currency-input input {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
background-color: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
position: sticky;
|
||||
bottom: 1rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
{% endblock customCSS %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h2 class="h4 mb-0">
|
||||
<i class="fas fa-file-invoice me-2"></i> New Sale Order
|
||||
</h2>
|
||||
<div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<form id="saleOrderForm" method="post">
|
||||
{% csrf_token %}
|
||||
<!-- Basic Information Section -->
|
||||
<div class="form-section">
|
||||
<h4 class="form-section-header">
|
||||
<i class="fas fa-info-circle me-2"></i> Basic Information
|
||||
</h4>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Estimate -->
|
||||
<div class="col-md-6">
|
||||
{{form.estimate|as_crispy_field}}
|
||||
</div>
|
||||
|
||||
<!-- Opportunity -->
|
||||
<div class="col-md-6">
|
||||
{{form.opportunity|as_crispy_field}}
|
||||
</div>
|
||||
|
||||
<!-- Customer -->
|
||||
<div class="col-md-6">
|
||||
{% if form.customer %}
|
||||
{{form.customer}}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Vehicle -->
|
||||
<div class="col-md-6">
|
||||
<label for="car" class="form-label required-field">Vehicles</label>
|
||||
<ul class="list-group">
|
||||
{% for car in data.cars %}
|
||||
<li class="list-group-item d-flex justify-content-around align-items-center">
|
||||
<span class="badge bg-info rounded-pill">{{ car.make }}</span>
|
||||
<span class="badge bg-info rounded-pill">{{ car.model }}</span>
|
||||
<span class="badge bg-info rounded-pill">{{ car.year }}</span>
|
||||
<span class="badge bg-info rounded-pill">{{ car.vin }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Payment Method -->
|
||||
<div class="col-md-6">
|
||||
{{form.payment_method|as_crispy_field}}
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="col-md-6">
|
||||
{{form.status|as_crispy_field}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Financial Details Section -->
|
||||
<div class="form-section">
|
||||
<h4 class="form-section-header">
|
||||
<i class="fas fa-money-bill-wave me-2"></i> Financial Details
|
||||
</h4>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Agreed Price -->
|
||||
<div class="col-md-6">
|
||||
{{form.agreed_price|as_crispy_field}}
|
||||
</div>
|
||||
|
||||
<!-- Down Payment Amount -->
|
||||
<div class="col-md-6">
|
||||
<div class="currency-input">
|
||||
{{form.down_payment_amount|as_crispy_field}}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Loan Amount -->
|
||||
<div class="col-md-6">
|
||||
{{form.loan_amount|as_crispy_field}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delivery Information Section -->
|
||||
<div class="form-section">
|
||||
<h4 class="form-section-header">
|
||||
<i class="fas fa-truck me-2"></i> Delivery Information
|
||||
</h4>
|
||||
|
||||
<div class="row g-3">
|
||||
|
||||
<!-- Expected Delivery Date -->
|
||||
<div class="col-md-6">
|
||||
{{form.expected_delivery_date|as_crispy_field}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="col-12">
|
||||
<label for="comments" class="form-label">Comments</label>
|
||||
<textarea class="form-control" id="comments" rows="3" placeholder="Enter any additional comments..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="form-actions mt-4">
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'estimate_detail' estimate.pk %}" type="button" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times me-2"></i> Cancel
|
||||
</a>
|
||||
<div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-check-circle me-2"></i> Submit Order
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% block customJS %}
|
||||
<!-- Custom JavaScript -->
|
||||
<script>
|
||||
/*
|
||||
// Calculate financial totals
|
||||
function calculateTotals() {
|
||||
const agreedPrice = parseFloat(document.getElementById('agreed_price').value) || 0;
|
||||
const downPayment = parseFloat(document.getElementById('down_payment_amount').value) || 0;
|
||||
const tradeInValue = parseFloat(document.getElementById('trade_in_value').value) || 0;
|
||||
const loanAmount = parseFloat(document.getElementById('loan_amount').value) || 0;
|
||||
|
||||
// Calculate total paid amount
|
||||
const totalPaid = downPayment + tradeInValue + loanAmount;
|
||||
document.getElementById('total_paid_amount').value = totalPaid.toFixed(2);
|
||||
|
||||
// Calculate remaining balance
|
||||
const remainingBalance = agreedPrice - totalPaid;
|
||||
document.getElementById('remaining_balance').value = remainingBalance > 0 ? remainingBalance.toFixed(2) : '0.00';
|
||||
}
|
||||
|
||||
// Show/hide cancellation fields based on status
|
||||
function toggleCancellationFields() {
|
||||
const status = document.getElementById('status').value;
|
||||
const cancellationFields = document.getElementById('cancellationFields');
|
||||
const cancellationReasonFields = document.getElementById('cancellationReasonFields');
|
||||
|
||||
if (status === 'CANCELLED') {
|
||||
cancellationFields.style.display = 'block';
|
||||
cancellationReasonFields.style.display = 'block';
|
||||
} else {
|
||||
cancellationFields.style.display = 'none';
|
||||
cancellationReasonFields.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Set current datetime for order date
|
||||
function setCurrentDateTime() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
|
||||
document.getElementById('order_date').value = `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// Initialize form
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Set current datetime
|
||||
setCurrentDateTime();
|
||||
|
||||
// Add event listeners for financial calculations
|
||||
document.getElementById('agreed_price').addEventListener('change', calculateTotals);
|
||||
document.getElementById('down_payment_amount').addEventListener('change', calculateTotals);
|
||||
document.getElementById('trade_in_value').addEventListener('change', calculateTotals);
|
||||
document.getElementById('loan_amount').addEventListener('change', calculateTotals);
|
||||
|
||||
// Add event listener for status change
|
||||
document.getElementById('status').addEventListener('change', toggleCancellationFields);
|
||||
|
||||
// Form submission
|
||||
document.getElementById('saleOrderForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
// In a real application, this would submit the form data to the server
|
||||
alert('Sale order would be submitted here');
|
||||
// window.location.href = '/sale-orders/';
|
||||
});
|
||||
});*/
|
||||
</script>
|
||||
{% endblock customJS %}
|
||||
619
templates/sales/orders/order_details.html
Normal file
619
templates/sales/orders/order_details.html
Normal file
@ -0,0 +1,619 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n humanize %}
|
||||
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
/* Custom CSS for additional styling */
|
||||
.status-badge {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 50rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 1px solid rgba(0,0,0,.125);
|
||||
}
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.timeline:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: #dee2e6;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.timeline-item:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.timeline-item:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1.5rem;
|
||||
top: 0.25rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
background-color: #0d6efd;
|
||||
}
|
||||
|
||||
.file-upload {
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.file-upload:hover {
|
||||
border-color: #0d6efd;
|
||||
background-color: rgba(13, 110, 253, 0.05);
|
||||
}
|
||||
|
||||
.document-thumbnail {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock customCSS %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-0">
|
||||
<!-- Header -->
|
||||
<header class="bg-primary text-white py-3">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 class="h4 mb-0">
|
||||
<i class="fas fa-file-invoice me-2"></i>
|
||||
Sale Order #{{ saleorder.formatted_order_id }}
|
||||
</h1>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-light me-2">
|
||||
<i class="fas fa-print me-1"></i> Print
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-light">
|
||||
<i class="fas fa-share-alt me-1"></i> Share
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="container py-4">
|
||||
<div class="row">
|
||||
<!-- Left Column -->
|
||||
<div class="col-lg-8 mb-4">
|
||||
<!-- Order Summary Card -->
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center bg-light">
|
||||
<h5 class="mb-0 text-primary">Order Summary</h5>
|
||||
<span class="status-badge
|
||||
{% if saleorder.status == 'approved' %}bg-success text-white
|
||||
{% elif saleorder.status == 'cancelled' %}bg-danger text-white
|
||||
{% elif saleorder.status == 'pending_approval' %}bg-warning text-dark
|
||||
{% elif saleorder.status == 'delivered' %}bg-info text-white
|
||||
{% else %}bg-secondary text-white{% endif %}">
|
||||
{{ saleorder.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Order Date</label>
|
||||
<p class="mb-0 fw-bold">{{ saleorder.order_date|date }}</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Customer</label>
|
||||
<p class="mb-0 fw-bold">{{ saleorder.customer.full_name|capfirst }}</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Payment Method</label>
|
||||
<p class="mb-0 fw-bold">{{ saleorder.get_payment_method_display }}</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Created By</label>
|
||||
<p class="mb-0 fw-bold">{{ saleorder.created_by }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Expected Delivery</label>
|
||||
<p class="mb-0 fw-bold">
|
||||
{% if saleorder.expected_delivery_date %}
|
||||
{{ saleorder.expected_delivery_date|date }}
|
||||
{% else %}
|
||||
<span class="text-warning">Not scheduled</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Last Updated</label>
|
||||
<p class="mb-0 fw-bold">
|
||||
{{ saleorder.updated_at|naturaltime|capfirst }} by
|
||||
{{ saleorder.last_modified_by }}
|
||||
</p>
|
||||
</div>
|
||||
{% if saleorder.status == 'cancelled' %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Cancellation Reason</label>
|
||||
<p class="mb-0 fw-bold text-danger">{{ saleorder.cancellation_reason|default:"Not specified" }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if saleorder.comments %}
|
||||
<div class="mt-3">
|
||||
<label class="form-label text-muted small mb-1">Order Comments</label>
|
||||
<blockquote class="blockquote mb-0">
|
||||
<p class="mb-0">{{ saleorder.comments }}</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vehicle Details Card -->
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Vehicle Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{% if data.cars %}
|
||||
{% for car in data.cars %}
|
||||
<div class="col-md-4 mb-3">
|
||||
<img src="{{ car.logo|default:'https://via.placeholder.com/300x200?text=Vehicle+Image' }}"
|
||||
alt="Vehicle" class="img-fluid rounded" width="200" height="100">
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label text-muted small mb-1">Make</label>
|
||||
<p class="mb-0">{{ car.make }}</p>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label text-muted small mb-1">Model</label>
|
||||
<p class="mb-0">{{ car.model }}</p>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label text-muted small mb-1">Year</label>
|
||||
<p class="mb-0">{{ car.year }}</p>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label text-muted small mb-1">VIN</label>
|
||||
<p class="mb-0">{{ car.vin }}</p>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label text-muted small mb-1">Mileage</label>
|
||||
<p class="mb-0">{{ car.mileage|intcomma }} km</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-4">
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="col-12 text-center py-4">
|
||||
<p class="text-muted">No vehicle assigned to this order</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Financial Details Card -->
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Financial Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Agreed Price</label>
|
||||
<p class="mb-0 fw-bold">SAR {{ saleorder.agreed_price|intcomma }}</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Down Payment</label>
|
||||
<p class="mb-0">SAR {{ saleorder.down_payment_amount|intcomma }}</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Trade-In Value</label>
|
||||
<p class="mb-0">SAR {{ saleorder.trade_in_value|intcomma }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Loan Amount</label>
|
||||
<p class="mb-0">SAR {{ saleorder.loan_amount|intcomma }}</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Total Paid</label>
|
||||
<p class="mb-0">SAR {{ saleorder.total_paid_amount|intcomma }}</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Remaining Balance</label>
|
||||
<p class="mb-0 fw-bold {% if saleorder.remaining_balance > 0 %}text-danger{% else %}text-success{% endif %}">
|
||||
SAR {{ saleorder.remaining_balance|intcomma }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress mt-3" style="height: 10px;">
|
||||
{% widthratio saleorder.total_paid_amount saleorder.agreed_price 100 as payment_percentage %}
|
||||
<div class="progress-bar bg-success" role="progressbar"
|
||||
style="width: {{ payment_percentage }}%;"
|
||||
aria-valuenow="{{ payment_percentage }}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"></div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mt-1 small text-muted">
|
||||
<span>{{ payment_percentage }}% Paid</span>
|
||||
<span>SAR {{ saleorder.agreed_price|intcomma }} Total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documents Card -->
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Documents</h5>
|
||||
<button class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-plus me-1"></i> Add Document
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="file-upload mb-3">
|
||||
<i class="fas fa-cloud-upload-alt fa-3x text-muted mb-2"></i>
|
||||
<p class="mb-1">Drag & drop files here or click to browse</p>
|
||||
<p class="small text-muted mb-0">PDF, JPG, PNG up to 10MB</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
{% for document in saleorder.documents.all %}
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card">
|
||||
{% if document.file.url|lower|slice:'-3:' == 'pdf' %}
|
||||
<img src="{% static 'images/pdf-icon.png' %}" class="document-thumbnail card-img-top" alt="PDF Document">
|
||||
{% else %}
|
||||
<img src="{{ document.file.url }}" class="document-thumbnail card-img-top" alt="Document">
|
||||
{% endif %}
|
||||
<div class="card-body p-2">
|
||||
<p class="card-text small mb-1">{{ document.get_filename }}</p>
|
||||
<p class="card-text small text-muted mb-0">Added: {{ document.created_at|date:"F j, Y" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-12 text-center py-3">
|
||||
<p class="text-muted">No documents uploaded yet</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comments Card -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Comments & Notes</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% comment %} <form method="post" action="{% url 'add_sale_order_comment' saleorder.pk %}"> {% endcomment %}
|
||||
<form method="post" action="">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<textarea class="form-control" name="comment" rows="3" placeholder="Add a comment or note..." required></textarea>
|
||||
<div class="d-flex justify-content-end mt-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="timeline">
|
||||
{% for comment in saleorder.comments.all %}
|
||||
<div class="timeline-item">
|
||||
<div class="card mb-2">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<strong>{{ comment.created_by.get_full_name|default:comment.created_by.username }}</strong>
|
||||
<small class="text-muted">{{ comment.created_at|date:"F j, Y H:i A" }}</small>
|
||||
</div>
|
||||
<p class="mb-0">{{ comment.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="text-center py-3">
|
||||
<p class="text-muted">No comments yet</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Actions Card -->
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Order Actions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
{% if saleorder.status == 'pending_approval' %}
|
||||
<button class="btn btn-success" onclick="updateStatus('approved')">
|
||||
<i class="fas fa-check-circle me-2"></i> Approve Order
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% comment %} <a href="{% url 'edit_sale_order' saleorder.pk %}" class="btn btn-primary"> {% endcomment %}
|
||||
<a href="" class="btn btn-primary">
|
||||
<i class="fas fa-edit me-2"></i> Edit Order
|
||||
</a>
|
||||
|
||||
{% if not saleorder.invoice %}
|
||||
{% comment %} <a href="{% url 'create_invoice_from_order' saleorder.pk %}" class="btn btn-info"> {% endcomment %}
|
||||
<a href="" class="btn btn-info">
|
||||
<i class="fas fa-file-invoice-dollar me-2"></i> Create Invoice
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if saleorder.status == 'approved' and not saleorder.actual_delivery_date %}
|
||||
<button class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#deliveryModal">
|
||||
<i class="fas fa-truck me-2"></i> Schedule Delivery
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if saleorder.status != 'cancelled' %}
|
||||
<button class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#cancelModal">
|
||||
<i class="fas fa-times-circle me-2"></i> Cancel Order
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Timeline Card -->
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Order Status Timeline</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="timeline">
|
||||
{% for log in saleorder.status_logs.all %}
|
||||
<div class="timeline-item">
|
||||
<div class="d-flex justify-content-between">
|
||||
<strong>{{ log.get_status_display }}</strong>
|
||||
<small class="text-muted">{{ log.created_at|date:"F j, Y" }}</small>
|
||||
</div>
|
||||
<p class="small mb-0">
|
||||
{% if log.note %}{{ log.note }}{% endif %}
|
||||
<br>
|
||||
<small class="text-muted">Changed by: {{ log.changed_by.get_full_name|default:log.changed_by.username }}</small>
|
||||
</p>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="text-center py-3">
|
||||
<p class="text-muted">No status history available</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Items Card -->
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Related Items</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Estimate</label>
|
||||
<a href="{% url 'estimate_detail' saleorder.estimate.pk %}" target="_blank" rel="noopener noreferrer">
|
||||
<p class="mb-0">
|
||||
<span class="badge bg-success ms-1">{{ saleorder.estimate.estimate_number }} <i class="fas fa-external-link-alt ms-2" style="font-size: 0.8rem;"></i></span>
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Invoice</label>
|
||||
<p class="mb-0">
|
||||
{% if saleorder.invoice %}
|
||||
<a href="{% url 'invoice_detail' saleorder.invoice.pk %}" target="_blank" rel="noopener noreferrer">
|
||||
<p class="mb-0">
|
||||
<span class="badge bg-success ms-1">{{ saleorder.invoice.invoice_number }} <i class="fas fa-external-link-alt ms-2" style="font-size: 0.8rem;"></i></span>
|
||||
</p>
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted">Not created yet</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Opportunity</label>
|
||||
<a href="{% url 'opportunity_detail' saleorder.opportunity.slug %}" target="_blank" rel="noopener noreferrer">
|
||||
<p class="mb-0">
|
||||
<span class="badge bg-success ms-1">{{ saleorder.opportunity }} <i class="fas fa-external-link-alt ms-2" style="font-size: 0.8rem;"></i></span>
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small mb-1">Customer</label>
|
||||
<a href="{% url 'customer_detail' saleorder.customer.slug %}" target="_blank" rel="noopener noreferrer">
|
||||
<p class="mb-0">
|
||||
<span class="badge bg-success ms-1">{{ saleorder.customer.full_name|capfirst }} <i class="fas fa-external-link-alt ms-2" style="font-size: 0.8rem;"></i></span>
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trade-In Vehicle Card -->
|
||||
{% if saleorder.trade_in_vehicle %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Trade-In Vehicle</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="text-center mb-3">
|
||||
<img src="{{ saleorder.trade_in_vehicle.image.url|default:'https://via.placeholder.com/300x200?text=Trade-In' }}"
|
||||
alt="Trade-In Vehicle" class="img-fluid rounded mb-2">
|
||||
<h6 class="mb-1">
|
||||
{{ saleorder.trade_in_vehicle.year }}
|
||||
{{ saleorder.trade_in_vehicle.make }}
|
||||
{{ saleorder.trade_in_vehicle.model }}
|
||||
</h6>
|
||||
<p class="small text-muted mb-2">VIN: {{ saleorder.trade_in_vehicle.vin }}</p>
|
||||
<p class="fw-bold">SAR {{ saleorder.trade_in_value|intcomma }}</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<p class="small mb-1">
|
||||
<i class="fas fa-tachometer-alt me-1 text-muted"></i>
|
||||
{{ saleorder.trade_in_vehicle.mileage|intcomma }} km
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<p class="small mb-1">
|
||||
<i class="fas fa-paint-brush me-1 text-muted"></i>
|
||||
{{ saleorder.trade_in_vehicle.color }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<p class="small mb-1">
|
||||
<i class="fas fa-gas-pump me-1 text-muted"></i>
|
||||
{{ saleorder.trade_in_vehicle.engine }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<p class="small mb-1">
|
||||
<i class="fas fa-cog me-1 text-muted"></i>
|
||||
{{ saleorder.trade_in_vehicle.get_transmission_display }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel Order Modal -->
|
||||
<div class="modal fade" id="cancelModal" tabindex="-1" aria-labelledby="cancelModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="cancelModalLabel">Cancel Order</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
{% comment %} <form method="post" action="{% url 'cancel_sale_order' saleorder.pk %}"> {% endcomment %}
|
||||
<form method="post" action="">
|
||||
{% csrf_token %}
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="cancellationReason" class="form-label">Reason for Cancellation</label>
|
||||
<textarea class="form-control" id="cancellationReason" name="cancellation_reason" rows="3" required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-danger">Confirm Cancellation</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Delivery Modal -->
|
||||
<div class="modal fade" id="deliveryModal" tabindex="-1" aria-labelledby="deliveryModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deliveryModalLabel">Schedule Delivery</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
{% comment %} <form method="post" action="{% url 'schedule_delivery' saleorder.pk %}"> {% endcomment %}
|
||||
<form method="post" action="">
|
||||
{% csrf_token %}
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="deliveryDate" class="form-label">Delivery Date</label>
|
||||
<input type="date" class="form-control" id="deliveryDate" name="delivery_date" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="deliveryNotes" class="form-label">Notes</label>
|
||||
<textarea class="form-control" id="deliveryNotes" name="notes" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-primary">Schedule Delivery</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
{% block customJS %}
|
||||
<script>
|
||||
// Status update function
|
||||
function updateStatus(newStatus) {
|
||||
fetch("", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: newStatus
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while updating status');
|
||||
});
|
||||
}
|
||||
|
||||
// Document upload handling
|
||||
document.querySelector('.file-upload').addEventListener('click', function() {
|
||||
// In a real application, this would open a file dialog
|
||||
alert('File upload dialog would open here');
|
||||
});
|
||||
|
||||
// Initialize tooltips
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock customJS %}
|
||||
@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n static %}
|
||||
{% load i18n static humanize %}
|
||||
|
||||
{% block title %}{{ _("Orders") }}{% endblock title %}
|
||||
|
||||
@ -14,6 +14,9 @@
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{% trans "Order Number" %}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{% trans "Customer" %}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{% trans "For Quotation" %}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{% trans "Invoice" %}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{% trans "Status" %}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col">{% trans "Expected Delivery" %}</th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col"></th>
|
||||
<th class="sort white-space-nowrap align-middle" scope="col"></th>
|
||||
</tr>
|
||||
@ -24,9 +27,21 @@
|
||||
<td class="align-middle product white-space-nowrap py-0">{{ order.formatted_order_id }}</td>
|
||||
<td class="align-middle product white-space-nowrap py-0">{{ order.estimate.customer.customer_name }}</td>
|
||||
<td class="align-middle product white-space-nowrap">
|
||||
<a href="{% url 'estimate_detail' order.estimate.pk %}">
|
||||
{{ order.estimate }}
|
||||
</a>
|
||||
<a href="{% url 'estimate_detail' order.estimate.pk %}">
|
||||
{{ order.estimate }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="align-middle product white-space-nowrap">
|
||||
{% if order.invoice %}
|
||||
<a href="{% url 'invoice_detail' order.invoice.pk %}">
|
||||
{{ order.invoice }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle product white-space-nowrap py-0">{{ order.status }}</td>
|
||||
<td class="align-middle product white-space-nowrap py-0">{{ order.expected_delivery_date|naturalday|capfirst }}</td>
|
||||
<td class="align-middle product white-space-nowrap py-0">
|
||||
<a class="btn btn-sm btn-success" href="{% url 'sale_order_details' order.estimate.pk order.pk %}">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user