Update Login Method & Prevent Duplicate Customer Data
This commit is contained in:
parent
99413dae2d
commit
86229809d8
41
README.md
41
README.md
@ -4,17 +4,20 @@ A lightweight, modern web application built with Python (Flask) that streamlines
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- **Direct Odoo Integration:** Seamlessly creates `res.partner` and `loyalty.card` records in Odoo 19.
|
||||
- **Data Mapping Automation:** Automatically maps database fields (including custom fields like `gender`, `birth_date`, and `total_spend`) and dynamically links the appropriate `loyalty.program` based on the user's membership level.
|
||||
- **Direct Odoo Integration:** Seamlessly creates or updates `res.partner` and `loyalty.card` records in Odoo 19.
|
||||
- **Smart Duplicate Prevention:** Automatically normalizes phone numbers (stripping non-digits and `0` or `62` prefixes) to find existing customers in Odoo. If a customer already exists, it intelligently fills in empty fields without overwriting existing data.
|
||||
- **Data Accumulation:** Accumulates (adds up) values for `total_spend` and loyalty `points` if the customer or loyalty card already exists in Odoo, rather than overwriting them.
|
||||
- **High-Performance DataTables:** Uses Server-Side Processing for pagination, sorting, and debounced searching. This ensures the UI remains lightning-fast, even with hundreds of thousands of records.
|
||||
- **Beautiful & Modern UI:** Features a clean, responsive "Glassmorphism" light theme optimized for both Desktop and Mobile environments.
|
||||
- **Secure Sessions:** Employee logins are secured with an 8-hour session lifetime mechanism.
|
||||
- **Secure MySQL Authentication:** Employee logins are handled via a separate MySQL database. Passwords are verified using encoding, with a fallback for a default unencrypted default password.
|
||||
- **Interactive Prompts:** Integrated with SweetAlert2 to prevent accidental migrations and to provide clean success/error feedback.
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
- **Backend:** Python 3, Flask, Flask-Session
|
||||
- **Database Connection:** `psycopg2-binary` (PostgreSQL)
|
||||
- **Databases:**
|
||||
- `psycopg2-binary` (PostgreSQL - for Member Data)
|
||||
- `PyMySQL` (MySQL - for Employee Login)
|
||||
- **Odoo Integration:** `xmlrpc.client` (Odoo XML-RPC API)
|
||||
- **Frontend:** HTML5, CSS3, Vanilla JavaScript, jQuery, DataTables, SweetAlert2
|
||||
|
||||
@ -39,10 +42,10 @@ A lightweight, modern web application built with Python (Flask) that streamlines
|
||||
```
|
||||
|
||||
4. **Configure Credentials**
|
||||
Open `app.py` in your text editor and locate the **Configurations** section at the top of the file. Update the placeholders with your actual PostgreSQL and Odoo 19 credentials:
|
||||
Open `app.py` in your text editor and locate the **Configurations** section at the top of the file. Update the placeholders with your actual PostgreSQL, MySQL, and Odoo 19 credentials:
|
||||
|
||||
```python
|
||||
# PostgreSQL
|
||||
# PostgreSQL (Member Data)
|
||||
DB_HOST = "localhost"
|
||||
DB_PORT = "5432"
|
||||
DB_NAME = "your_postgres_db"
|
||||
@ -50,10 +53,17 @@ A lightweight, modern web application built with Python (Flask) that streamlines
|
||||
DB_PASS = "your_postgres_password"
|
||||
|
||||
# Odoo 19
|
||||
ODOO_URL = "localhost"
|
||||
ODOO_DB = "odoo19"
|
||||
ODOO_URL = "http://localhost:8069"
|
||||
ODOO_DB = "odoo19_db"
|
||||
ODOO_USER = "admin"
|
||||
ODOO_PASS = "admin"
|
||||
|
||||
# MySQL (Login DB)
|
||||
MYSQL_HOST = "localhost"
|
||||
MYSQL_PORT = 3306
|
||||
MYSQL_DB = "klaim_db"
|
||||
MYSQL_USER = "your_mysql_user"
|
||||
MYSQL_PASS = "your_mysql_password"
|
||||
```
|
||||
|
||||
5. **Run the Application**
|
||||
@ -70,21 +80,22 @@ When a migration is triggered, the following mapping occurs:
|
||||
| PostgreSQL (`tada_member`) | Odoo 19 (`res.partner`) | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| `name` | `name` | |
|
||||
| `phone_number` | `phone` | |
|
||||
| `email` | `email` | |
|
||||
| `phone_number` | `phone` | Normalized for searching to prevent duplicates |
|
||||
| `email` | `email` | Updates only if Odoo field is empty |
|
||||
| `gender` | `gender` | Capitalized automatically (e.g., `female` -> `Female`) |
|
||||
| `birthday` | `birth_date` | Converted to string format |
|
||||
| `city` | `city` | |
|
||||
| `total_spending` | `total_spend` | Converted to Float |
|
||||
| `birthday` | `birth_date` | Updates only if Odoo field is empty |
|
||||
| `city` | `city` | Updates only if Odoo field is empty |
|
||||
| `total_spending` | `total_spend` | Converted to Float. **Accumulates** if data exists. |
|
||||
| `level` | `membership_level_id` | Linked via `loyalty.program` search (Many2one) |
|
||||
|
||||
### 2. `loyalty.card` (Membership Card)
|
||||
| PostgreSQL (`tada_member`) | Odoo 19 (`loyalty.card`) | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| `point_amount` | `points` | Converted to Float |
|
||||
| (Generated) | `partner_id` | Linked to the newly created `res.partner` ID |
|
||||
| `point_amount` | `points` | Converted to Float. **Accumulates** if card exists. |
|
||||
| (Generated) | `partner_id` | Linked to the matched/created `res.partner` ID |
|
||||
| (Generated) | `program_id` | Linked to the matched `loyalty.program` ID |
|
||||
|
||||
## 🛡️ Important Notes
|
||||
- **Data Validation:** The script automatically handles `NULL`/`None` values from PostgreSQL, safely converting them to `False` to prevent XML-RPC marshal errors when writing to Odoo.
|
||||
- **Debounce Optimization:** The search bar waits 400ms after you stop typing and requires a minimum of 3 characters before querying the database, drastically reducing server load.
|
||||
- **Session Expiry:** Security sessions automatically expire after 8 hours of inactivity.
|
||||
|
||||
178
app.py
178
app.py
@ -1,5 +1,8 @@
|
||||
import xmlrpc.client
|
||||
import psycopg2
|
||||
import pymysql
|
||||
import base64
|
||||
import re
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from flask import Flask, render_template, request, session, redirect, url_for, jsonify
|
||||
from datetime import datetime, timedelta
|
||||
@ -23,6 +26,34 @@ ODOO_DB = "odoo19_OT_v3"
|
||||
ODOO_USER = "admin"
|
||||
ODOO_PASS = "admin"
|
||||
|
||||
# MySQL (Login DB)
|
||||
MYSQL_HOST = "192.169.0.13"
|
||||
MYSQL_PORT = 3306
|
||||
MYSQL_DB = "klaim_db"
|
||||
MYSQL_USER = "u8975009_root"
|
||||
MYSQL_PASS = "Jempol&1992Mutu_hosting"
|
||||
|
||||
def get_mysql_connection():
|
||||
return pymysql.connect(
|
||||
host=MYSQL_HOST,
|
||||
port=MYSQL_PORT,
|
||||
user=MYSQL_USER,
|
||||
password=MYSQL_PASS,
|
||||
database=MYSQL_DB,
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
|
||||
def normalize_phone(phone):
|
||||
if not phone:
|
||||
return ""
|
||||
# Remove all non-digits
|
||||
num = re.sub(r'\D', '', str(phone))
|
||||
if num.startswith('62'):
|
||||
num = num[2:]
|
||||
elif num.startswith('0'):
|
||||
num = num[1:]
|
||||
return num
|
||||
|
||||
def get_db_connection():
|
||||
conn = psycopg2.connect(
|
||||
host=DB_HOST,
|
||||
@ -37,21 +68,55 @@ def get_db_connection():
|
||||
def index():
|
||||
if 'employee_id' not in session:
|
||||
return redirect(url_for('login'))
|
||||
return render_template('index.html', employee_id=session['employee_id'])
|
||||
return render_template('index.html', employee_name=session.get('employee_name', session['employee_id']))
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
error = None
|
||||
if request.method == 'POST':
|
||||
employee_id = request.form.get('employee_id')
|
||||
if employee_id:
|
||||
session.permanent = True
|
||||
session['employee_id'] = employee_id
|
||||
return redirect(url_for('index'))
|
||||
return render_template('login.html')
|
||||
password = request.form.get('password')
|
||||
|
||||
if employee_id and password:
|
||||
try:
|
||||
conn = get_mysql_connection()
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SELECT EMP_FULL_NAME, EMP_PASS FROM m_employee WHERE EMP_F_NUM = %s", (employee_id,))
|
||||
user = cursor.fetchone()
|
||||
|
||||
if user:
|
||||
db_pass_encoded = user['EMP_PASS']
|
||||
|
||||
# Check default unencrypted password first
|
||||
if password == "11111" and db_pass_encoded == "11111":
|
||||
is_valid = True
|
||||
else:
|
||||
input_pass_encoded = base64.b64encode(password.encode('utf-8')).decode('utf-8')
|
||||
is_valid = (input_pass_encoded == db_pass_encoded)
|
||||
|
||||
if is_valid:
|
||||
session.permanent = True
|
||||
session['employee_id'] = employee_id
|
||||
session['employee_name'] = user['EMP_FULL_NAME']
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
error = "Invalid password."
|
||||
else:
|
||||
error = "Employee ID not found."
|
||||
except Exception as e:
|
||||
error = f"Database error: {str(e)}"
|
||||
finally:
|
||||
if 'conn' in locals() and conn.open:
|
||||
conn.close()
|
||||
else:
|
||||
error = "Please provide both Employee ID and Password."
|
||||
|
||||
return render_template('login.html', error=error)
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
session.pop('employee_id', None)
|
||||
session.pop('employee_name', None)
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@app.route('/api/customers')
|
||||
@ -68,7 +133,7 @@ def get_customers():
|
||||
order_column_index = request.args.get('order[0][column]', type=int, default=0)
|
||||
order_dir = request.args.get('order[0][dir]', default='asc')
|
||||
|
||||
columns_map = ['id', 'name', 'phone_number', 'email', 'level', 'point_amount', 'responsible', 'total_spending']
|
||||
columns_map = ['id', 'name', 'phone_number', 'email', 'level', 'point_amount', 'responsible']
|
||||
order_column = columns_map[order_column_index] if order_column_index < len(columns_map) else 'id'
|
||||
if order_dir not in ['asc', 'desc']:
|
||||
order_dir = 'asc'
|
||||
@ -151,7 +216,23 @@ def migrate_customer(customer_id):
|
||||
|
||||
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
|
||||
|
||||
# 1. Find loyalty.program based on level
|
||||
# 1. Normalize phone and check if partner exists
|
||||
phone_raw = customer.get('phone_number') or ''
|
||||
norm_phone = normalize_phone(phone_raw)
|
||||
|
||||
partner_id = False
|
||||
existing_partner = None
|
||||
|
||||
if norm_phone:
|
||||
partner_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'res.partner', 'search_read',
|
||||
[[('phone', 'ilike', norm_phone)]],
|
||||
{'fields': ['name', 'phone', 'email', 'gender', 'birth_date', 'city', 'total_spend', 'membership_level_id'], 'limit': 1}
|
||||
)
|
||||
if partner_ids:
|
||||
existing_partner = partner_ids[0]
|
||||
partner_id = existing_partner['id']
|
||||
|
||||
# 2. Find loyalty.program based on level
|
||||
level = customer.get('level', '')
|
||||
program_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'loyalty.program', 'search', [[('name', 'ilike', level)]])
|
||||
|
||||
@ -165,32 +246,67 @@ def migrate_customer(customer_id):
|
||||
if isinstance(gender_val, str) and gender_val:
|
||||
gender_val = gender_val.title()
|
||||
|
||||
# 2. Create res.partner
|
||||
partner_data = {
|
||||
'name': customer.get('name', ''),
|
||||
'phone': customer.get('phone_number', ''),
|
||||
'email': customer.get('email', ''),
|
||||
'gender': gender_val or '',
|
||||
'birth_date': str(customer.get('birthday', '')) if customer.get('birthday') else False,
|
||||
'city': customer.get('city', ''),
|
||||
'membership_level_id': program_id,
|
||||
'total_spend': float(customer.get('total_spending') or 0.0)
|
||||
}
|
||||
pg_total_spend = float(customer.get('total_spending') or 0.0)
|
||||
|
||||
# Replace None with False for Odoo XML-RPC compatibility
|
||||
partner_data = {k: (v if v is not None else False) for k, v in partner_data.items()}
|
||||
if existing_partner:
|
||||
# Update existing partner (fill empty fields)
|
||||
update_data = {}
|
||||
if not existing_partner.get('email') and customer.get('email'):
|
||||
update_data['email'] = customer.get('email')
|
||||
if not existing_partner.get('gender') and gender_val:
|
||||
update_data['gender'] = gender_val
|
||||
if not existing_partner.get('birth_date') and customer.get('birthday'):
|
||||
update_data['birth_date'] = str(customer.get('birthday'))
|
||||
if not existing_partner.get('city') and customer.get('city'):
|
||||
update_data['city'] = customer.get('city')
|
||||
if not existing_partner.get('membership_level_id'):
|
||||
update_data['membership_level_id'] = program_id
|
||||
|
||||
# Accumulate total_spend
|
||||
current_spend = float(existing_partner.get('total_spend') or 0.0)
|
||||
if pg_total_spend > 0:
|
||||
update_data['total_spend'] = current_spend + pg_total_spend
|
||||
|
||||
if update_data:
|
||||
models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'res.partner', 'write', [[partner_id], update_data])
|
||||
else:
|
||||
# Create new partner
|
||||
partner_data = {
|
||||
'name': customer.get('name', ''),
|
||||
'phone': phone_raw,
|
||||
'email': customer.get('email', ''),
|
||||
'gender': gender_val or '',
|
||||
'birth_date': str(customer.get('birthday', '')) if customer.get('birthday') else False,
|
||||
'city': customer.get('city', ''),
|
||||
'membership_level_id': program_id,
|
||||
'total_spend': pg_total_spend
|
||||
}
|
||||
# Replace None with False for Odoo XML-RPC compatibility
|
||||
partner_data = {k: (v if v is not None else False) for k, v in partner_data.items()}
|
||||
partner_id = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'res.partner', 'create', [partner_data])
|
||||
|
||||
# 3. Handle loyalty.card
|
||||
point_amount = float(customer.get('point_amount') or 0.0)
|
||||
|
||||
partner_id = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'res.partner', 'create', [partner_data])
|
||||
card_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'loyalty.card', 'search_read',
|
||||
[[('partner_id', '=', partner_id), ('program_id', '=', program_id)]],
|
||||
{'fields': ['points'], 'limit': 1}
|
||||
)
|
||||
|
||||
# 3. Create loyalty.card
|
||||
point_amount = customer.get('point_amount')
|
||||
card_data = {
|
||||
'partner_id': partner_id,
|
||||
'program_id': program_id,
|
||||
'points': float(point_amount) if point_amount is not None else 0.0
|
||||
}
|
||||
|
||||
card_id = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'loyalty.card', 'create', [card_data])
|
||||
if card_ids:
|
||||
# Accumulate points
|
||||
existing_card = card_ids[0]
|
||||
current_points = float(existing_card.get('points') or 0.0)
|
||||
if point_amount > 0:
|
||||
models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'loyalty.card', 'write', [[existing_card['id']], {'points': current_points + point_amount}])
|
||||
else:
|
||||
# Create new card
|
||||
card_data = {
|
||||
'partner_id': partner_id,
|
||||
'program_id': program_id,
|
||||
'points': point_amount
|
||||
}
|
||||
models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'loyalty.card', 'create', [card_data])
|
||||
|
||||
# 4. Update tada_member
|
||||
now = datetime.now()
|
||||
|
||||
@ -2,6 +2,4 @@ Flask==3.0.0
|
||||
Flask-Session==0.5.0
|
||||
psycopg2-binary==2.9.9
|
||||
Werkzeug==3.0.1
|
||||
pandas
|
||||
sqlalchemy
|
||||
openpyxl
|
||||
PyMySQL==1.1.0
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<div class="nav-user">
|
||||
<span class="user-badge">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
|
||||
{{ employee_id }}
|
||||
{{ employee_name }}
|
||||
</span>
|
||||
<a href="{{ url_for('logout') }}" class="btn-logout">
|
||||
Log out
|
||||
|
||||
@ -7,14 +7,24 @@
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>
|
||||
</div>
|
||||
<h1 class="title">Tada Migration</h1>
|
||||
<p class="subtitle">Enter your catapa ID to securely access the migration portal.</p>
|
||||
<p class="subtitle">Enter your Catapa ID to securely access the migration portal.</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger" style="color: #ef4444; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2); padding: 0.75rem; border-radius: 8px; margin-bottom: 1.5rem; font-size: 0.875rem;">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form action="{{ url_for('login') }}" method="POST" class="login-form">
|
||||
<div class="input-group">
|
||||
<label for="employee_id">Catapa ID</label>
|
||||
<input type="text" id="employee_id" name="employee_id" placeholder="14045" required autofocus>
|
||||
<input type="text" id="employee_id" name="employee_id" placeholder="e.g. 14045" required autofocus>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Login</button>
|
||||
<div class="input-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" placeholder="••••••••" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Log In</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user