Compare commits

...

40 Commits

Author SHA1 Message Date
90e6af08d8 django easy audit implemetation 2025-06-04 20:41:58 +03:00
be037e7fa6 audit logs 2025-06-03 21:03:32 +03:00
f41aaf14f7 Merge branch 'main' of http://10.10.1.136:3000/ismail/haikal into frontend 2025-06-02 17:59:46 +03:00
a098ecef5b edit color 2025-06-02 17:59:28 +03:00
351d23a6d8 update the lead transfer queryset 2025-06-02 17:58:41 +03:00
a430a7ffd9 added the color update 2025-06-02 17:26:42 +03:00
f79a45b470 check2 2025-06-02 17:04:35 +03:00
12e5f291c6 check1 2025-06-02 16:56:42 +03:00
cafb12818a car_detail 1 2025-06-02 16:45:28 +03:00
75808b633b changes on 2/06 2025-06-02 16:40:10 +03:00
5a6bd16d45 Merge branch 'main' of http://10.10.1.136:3000/ismail/haikal into frontend 2025-06-02 14:50:32 +03:00
5c22021f18 added the padding 2025-06-02 14:50:26 +03:00
1d16c54811 more updates 2025-06-02 14:30:03 +03:00
fb3fb4f3d6 Merge branch 'main' of http://10.10.1.136:3000/ismail/haikal into frontend 2025-06-02 14:17:54 +03:00
591bdf9234 update the lead and opportunity detail page + more fixes 2025-06-02 14:16:56 +03:00
Marwan Alwali
c206a018f7 update 2025-06-02 10:59:00 +03:00
0e1ac45574 Merge branch 'main' of http://10.10.1.136:3000/ismail/haikal into frontend 2025-06-01 17:50:14 +03:00
32cae30158 add the IntegrityError chech to user_create 2025-06-01 17:49:49 +03:00
a5647ecd85 new vhnges 2025-06-01 17:40:16 +03:00
1ca769d451 Merge branch 'main' of http://10.10.1.136:3000/ismail/haikal into frontend 2025-06-01 17:39:47 +03:00
9cb98f7077 fix some stuff 2025-06-01 17:39:17 +03:00
5060f7dda3 Merge branch 'main' of http://10.10.1.136:3000/ismail/haikal into frontend 2025-06-01 16:28:43 +03:00
1d95da2b4b update 2025-06-01 16:28:20 +03:00
4baf6b7ac9 major changes 1 2025-06-01 16:24:05 +03:00
bc1b333e3e Merge branch 'main' of http://10.10.1.136:3000/ismail/haikal into frontend 2025-06-01 16:22:44 +03:00
8db2c44f64 fix the task complete partials not working correctly 2025-06-01 16:19:54 +03:00
4d4a0b8077 Merge branch 'main' of http://10.10.1.136:3000/ismail/haikal into frontend 2025-06-01 15:48:36 +03:00
072ddbba0c upto date on 1/6 2025-06-01 15:48:19 +03:00
f33875e224 fix the lead activity and tasks migration to opportuninty 2025-06-01 15:46:48 +03:00
172f830645 car detail colors edit page 2025-06-01 15:25:48 +03:00
7b290e84ba fr 2025-06-01 15:17:05 +03:00
5b4d6bf2b2 changes 2025-06-01 14:16:02 +03:00
b8079ebf97 update on the lead and opportunity+more 2025-06-01 13:19:43 +03:00
Marwan Alwali
638d3854af update 2025-05-31 11:56:00 +03:00
Marwan Alwali
56cfbad80e update 2025-05-29 21:42:27 +03:00
fb0d7f0f20 create car dealer make when new car is added 2025-05-27 17:18:09 +03:00
18c8c09d6c change header for reports 2025-05-26 20:58:55 +03:00
6a14d4c0c6 fix the perms and the car.name issue 2025-05-26 20:42:23 +03:00
21df3c3558 update header file perms 2025-05-26 20:25:29 +03:00
73338c303f Merge pull request 'User management changes' (#54) from frontend into main
Reviewed-on: #54
2025-05-26 18:28:15 +03:00
91 changed files with 10719 additions and 3685 deletions

BIN
.DS_Store vendored

Binary file not shown.

1
.gitignore vendored
View File

@ -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 #

View File

@ -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" />

View File

@ -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

Binary file not shown.

View File

@ -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.

View File

@ -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
View 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
}

View File

@ -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
}

View File

@ -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
View 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

View File

@ -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

Binary file not shown.

View File

View 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}"))

View 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',
},
),
]

View File

@ -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'),
),
]

View File

@ -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')],
},
),
]

View File

@ -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'),
),
]

Binary file not shown.

View File

@ -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'
```

View 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)

View File

@ -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
}

View File

@ -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)}")

View File

@ -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)})

View File

@ -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

View File

@ -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

View File

@ -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. تحافظ على السياق عبر استعلامات متعددة ذات صلة عند توفير معلومات الجلسة

View File

@ -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
View 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

View File

@ -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

File diff suppressed because it is too large Load Diff

View 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)}'))

View 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}"))

View 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."))

View 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".'))

View File

@ -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')),

View File

@ -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),
),
]

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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):
"""

View File

@ -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

View File

@ -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)]

View File

@ -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'),
]

View File

@ -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()

View File

@ -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

View File

@ -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

Binary file not shown.

1356
static/icons/HaikalAi.ai Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

21
t1.py Normal file
View 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

Binary file not shown.

View 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 %}

View 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 %}

View File

@ -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 %}

View 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 %}

View 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 %}

View File

@ -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>

View 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>

View File

@ -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>&nbsp;
<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>&nbsp;{{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> &nbsp; <small>{% trans "Next Action" %} :</small>&nbsp; <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>

View File

@ -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>

View File

@ -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>

View File

@ -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">

View 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>

View File

@ -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 %}

View File

@ -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 + '%';

View File

@ -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

Binary file not shown.

View 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 %}

View File

@ -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>

View File

@ -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 %}

View File

@ -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="">

View File

@ -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>

View File

@ -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>

View File

@ -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 }}

View File

@ -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 %}

View 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 %}

View 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>

View 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 %}

View 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 %}

View File

@ -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 %}