Initial Commit
This commit is contained in:
commit
b84cee0a9e
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
.venv
|
||||
90
README.md
Normal file
90
README.md
Normal file
@ -0,0 +1,90 @@
|
||||
# Tada to Odoo 19 Migration Portal
|
||||
|
||||
A lightweight, modern web application built with Python (Flask) that streamlines the process of migrating member/customer data from a PostgreSQL database (`tada_member`) directly into an Odoo 19 instance via XML-RPC.
|
||||
|
||||
## 🚀 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.
|
||||
- **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.
|
||||
- **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)
|
||||
- **Odoo Integration:** `xmlrpc.client` (Odoo XML-RPC API)
|
||||
- **Frontend:** HTML5, CSS3, Vanilla JavaScript, jQuery, DataTables, SweetAlert2
|
||||
|
||||
## 📦 Installation & Setup
|
||||
|
||||
1. **Clone or Extract the Repository**
|
||||
Ensure you are in the project root directory.
|
||||
|
||||
2. **Create a Virtual Environment (Optional but Recommended)**
|
||||
```bash
|
||||
python -m venv venv
|
||||
# On Windows:
|
||||
venv\Scripts\activate
|
||||
# On macOS/Linux:
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
3. **Install Dependencies**
|
||||
Install the required Python packages using pip:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```python
|
||||
# PostgreSQL
|
||||
DB_HOST = "localhost"
|
||||
DB_PORT = "5432"
|
||||
DB_NAME = "your_postgres_db"
|
||||
DB_USER = "your_postgres_user"
|
||||
DB_PASS = "your_postgres_password"
|
||||
|
||||
# Odoo 19
|
||||
ODOO_URL = "localhost"
|
||||
ODOO_DB = "odoo19"
|
||||
ODOO_USER = "admin"
|
||||
ODOO_PASS = "admin"
|
||||
```
|
||||
|
||||
5. **Run the Application**
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
The application will start a local development server. Open your web browser and navigate to: `http://localhost:5000`
|
||||
|
||||
## 🔄 Data Mapping Reference
|
||||
|
||||
When a migration is triggered, the following mapping occurs:
|
||||
|
||||
### 1. `res.partner` (Customer Profile)
|
||||
| PostgreSQL (`tada_member`) | Odoo 19 (`res.partner`) | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| `name` | `name` | |
|
||||
| `phone_number` | `phone` | |
|
||||
| `email` | `email` | |
|
||||
| `gender` | `gender` | Capitalized automatically (e.g., `female` -> `Female`) |
|
||||
| `birthday` | `birth_date` | Converted to string format |
|
||||
| `city` | `city` | |
|
||||
| `total_spending` | `total_spend` | Converted to Float |
|
||||
| `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 |
|
||||
| (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.
|
||||
212
app.py
Normal file
212
app.py
Normal file
@ -0,0 +1,212 @@
|
||||
import xmlrpc.client
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from flask import Flask, render_template, request, session, redirect, url_for, jsonify
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
app = Flask(__name__)
|
||||
# Secret key for sessions
|
||||
app.secret_key = 'super_secret_tada_migration_key_change_me_in_prod'
|
||||
app.permanent_session_lifetime = timedelta(hours=8)
|
||||
|
||||
# --- Configurations ---
|
||||
# PostgreSQL (Hardcoded as requested)
|
||||
DB_HOST = "192.169.0.10"
|
||||
DB_PORT = "5432"
|
||||
DB_NAME = "postgres"
|
||||
DB_USER = "postgres"
|
||||
DB_PASS = "Mulut!23Berkomunikasi"
|
||||
|
||||
# Odoo 19
|
||||
ODOO_URL = "http://localhost:1900"
|
||||
ODOO_DB = "odoo19_OT_v3"
|
||||
ODOO_USER = "admin"
|
||||
ODOO_PASS = "admin"
|
||||
|
||||
def get_db_connection():
|
||||
conn = psycopg2.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
dbname=DB_NAME,
|
||||
user=DB_USER,
|
||||
password=DB_PASS
|
||||
)
|
||||
return conn
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
if 'employee_id' not in session:
|
||||
return redirect(url_for('login'))
|
||||
return render_template('index.html', employee_id=session['employee_id'])
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
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')
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
session.pop('employee_id', None)
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@app.route('/api/customers')
|
||||
def get_customers():
|
||||
if 'employee_id' not in session:
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
# DataTables parameters
|
||||
draw = request.args.get('draw', type=int, default=1)
|
||||
start = request.args.get('start', type=int, default=0)
|
||||
length = request.args.get('length', type=int, default=10)
|
||||
search_value = request.args.get('search[value]', default="")
|
||||
|
||||
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']
|
||||
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'
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# Base query
|
||||
query = "SELECT * FROM tada_member"
|
||||
count_query = "SELECT COUNT(*) FROM tada_member"
|
||||
params = []
|
||||
|
||||
if search_value:
|
||||
search_pattern = f"%{search_value}%"
|
||||
where_clause = " WHERE name ILIKE %s OR phone_number ILIKE %s OR email ILIKE %s"
|
||||
query += where_clause
|
||||
count_query += where_clause
|
||||
params.extend([search_pattern, search_pattern, search_pattern])
|
||||
|
||||
# Get total filtered count
|
||||
cursor.execute(count_query, params)
|
||||
total_filtered = cursor.fetchone()['count']
|
||||
|
||||
# Total records without filter
|
||||
cursor.execute("SELECT COUNT(*) FROM tada_member")
|
||||
total_records = cursor.fetchone()['count']
|
||||
|
||||
# Add pagination and sorting
|
||||
query += f" ORDER BY {order_column} {order_dir.upper()} LIMIT %s OFFSET %s"
|
||||
params.extend([length, start])
|
||||
|
||||
cursor.execute(query, params)
|
||||
customers = cursor.fetchall()
|
||||
|
||||
# Convert date fields to string if any to allow JSON serialization
|
||||
for row in customers:
|
||||
if row.get('birthday'):
|
||||
row['birthday'] = str(row['birthday'])
|
||||
if row.get('updated_at'):
|
||||
row['updated_at'] = str(row['updated_at'])
|
||||
|
||||
return jsonify({
|
||||
"draw": draw,
|
||||
"recordsTotal": total_records,
|
||||
"recordsFiltered": total_filtered,
|
||||
"data": customers
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error fetching customers: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
@app.route('/api/migrate/<int:customer_id>', methods=['POST'])
|
||||
def migrate_customer(customer_id):
|
||||
if 'employee_id' not in session:
|
||||
return jsonify({"success": False, "message": "Unauthorized"}), 401
|
||||
|
||||
employee_id = session['employee_id']
|
||||
conn = None
|
||||
try:
|
||||
# Fetch customer data
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cursor.execute("SELECT * FROM tada_member WHERE id = %s", (customer_id,))
|
||||
customer = cursor.fetchone()
|
||||
|
||||
if not customer:
|
||||
return jsonify({"success": False, "message": "Customer not found."}), 404
|
||||
|
||||
# Odoo connection
|
||||
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
|
||||
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {})
|
||||
|
||||
if not uid:
|
||||
return jsonify({"success": False, "message": "Failed to authenticate with Odoo."}), 500
|
||||
|
||||
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
|
||||
|
||||
# 1. 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)]])
|
||||
|
||||
if not program_ids:
|
||||
return jsonify({"success": False, "message": f"Loyalty program containing '{level}' not found in Odoo."}), 404
|
||||
|
||||
program_id = program_ids[0]
|
||||
|
||||
# Map gender safely (e.g. 'female' -> 'Female')
|
||||
gender_val = customer.get('gender')
|
||||
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)
|
||||
}
|
||||
|
||||
# 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. 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])
|
||||
|
||||
# 4. Update tada_member
|
||||
now = datetime.now()
|
||||
cursor.execute("UPDATE tada_member SET responsible = %s, updated_at = %s WHERE id = %s", (employee_id, now, customer_id))
|
||||
conn.commit()
|
||||
|
||||
return jsonify({"success": True, "message": "Migrasi data berhasil!"})
|
||||
|
||||
except Exception as e:
|
||||
if conn:
|
||||
conn.rollback()
|
||||
print(f"Migration Error: {e}")
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, port=5000, host='0.0.0.0')
|
||||
161
import.py
Normal file
161
import.py
Normal file
@ -0,0 +1,161 @@
|
||||
import pandas as pd
|
||||
import re
|
||||
from sqlalchemy import create_engine
|
||||
import concurrent.futures
|
||||
|
||||
# ==========================================
|
||||
# KONFIGURASI DATABASE & FILE
|
||||
# ==========================================
|
||||
DB_USER = 'postgres'
|
||||
DB_PASSWORD = 'Mulut!23Berkomunikasi'
|
||||
DB_HOST = '192.169.0.10' # atau IP server DB Anda
|
||||
DB_PORT = '5432'
|
||||
DB_NAME = 'postgres'
|
||||
TABLE_NAME = 'tada_member'
|
||||
|
||||
FILE_EXCEL = 'C:/Users/abdul.aziz/Downloads/customer-data-rawformat-2604230703365650-1.xlsx'
|
||||
|
||||
# ==========================================
|
||||
# KONFIGURASI MULTI-THREADING
|
||||
# ==========================================
|
||||
MAX_THREADS = 16 # Atur jumlah thread secara dinamis (misal: 4, 8, atau 16)
|
||||
CHUNK_SIZE = 1000 # Jumlah baris data yang diinsert per thread/proses
|
||||
|
||||
# ==========================================
|
||||
# MAPPING KOLOM (SANGAT PENTING)
|
||||
# ==========================================
|
||||
# Sesuaikan key (kiri) dengan nama kolom di EXCEL.
|
||||
# Sesuaikan value (kanan) dengan nama kolom di POSTGRESQL (berdasarkan screenshot Anda).
|
||||
COLUMN_MAPPING = {
|
||||
'Card Number': 'card_number',
|
||||
'Name': 'name',
|
||||
'Phone Number': 'phone_number',
|
||||
'Email': 'email',
|
||||
'Gender': 'gender',
|
||||
'Birthday': 'birthday',
|
||||
'City': 'city',
|
||||
'Card Status': 'card_status',
|
||||
'Level': 'level',
|
||||
'Wallet Info': 'point_amount', # Ini nanti akan berisi angka yang sudah di-ekstrak
|
||||
'Total Spending (IDR)': 'total_spending',
|
||||
}
|
||||
|
||||
def extract_wallet_amount(text):
|
||||
"""
|
||||
Fungsi untuk mengekstrak angka amount dari teks Wallet Info.
|
||||
Target: "[wallet name: Poin, amount: 127600, ..." -> 127600
|
||||
"""
|
||||
if pd.isna(text):
|
||||
return None
|
||||
|
||||
# Regex untuk mengambil angka setelah "[wallet name: Poin, amount: " dan sebelum ","
|
||||
# \s* mengatasi spasi yang mungkin tidak konsisten
|
||||
match = re.search(r"\[wallet name:\s*Poin,\s*amount:\s*([^,]+)", str(text))
|
||||
|
||||
if match:
|
||||
try:
|
||||
return float(match.group(1).strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
def clean_phone_number(text):
|
||||
"""
|
||||
Membersihkan akhiran .0 pada nomor HP jika terbaca sebagai float oleh pandas.
|
||||
"""
|
||||
if pd.isna(text):
|
||||
return None
|
||||
|
||||
# Ubah menjadi string dan hapus spasi berlebih
|
||||
phone_str = str(text).strip()
|
||||
|
||||
# Jika berakhiran .0, potong 2 karakter terakhir
|
||||
if phone_str.endswith('.0'):
|
||||
phone_str = phone_str[:-2]
|
||||
|
||||
return phone_str
|
||||
|
||||
def insert_data_chunk(chunk, engine, table_name, chunk_id):
|
||||
"""
|
||||
Fungsi worker untuk thread: memasukkan potongan data (chunk) ke database.
|
||||
"""
|
||||
try:
|
||||
# if_exists='append' untuk menambah data ke tabel yang sudah ada
|
||||
chunk.to_sql(table_name, engine, if_exists='append', index=False)
|
||||
print(f"✅ Chunk {chunk_id} berhasil di-insert ({len(chunk)} baris).")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ Error pada Chunk {chunk_id}: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
# 1. Dapatkan daftar kolom langsung dari mapping (tanpa perlu baca warna background)
|
||||
target_cols = list(COLUMN_MAPPING.keys())
|
||||
|
||||
# 2. Baca semua sheet dari Excel
|
||||
print("Membaca semua sheet dari file Excel...")
|
||||
# sheet_name=None akan membaca semua sheet menjadi dictionary {nama_sheet: dataframe}
|
||||
all_sheets_dict = pd.read_excel(FILE_EXCEL, sheet_name=None)
|
||||
|
||||
# 3. Gabungkan semua sheet menjadi satu DataFrame
|
||||
df_combined = pd.concat(all_sheets_dict.values(), ignore_index=True)
|
||||
print(f"Total data tergabung dari semua sheet: {len(df_combined)} baris.")
|
||||
|
||||
# 4. Filter kolom: Hanya ambil kolom yang kita butuhkan (sesuai keys di mapping)
|
||||
available_target_cols = [col for col in target_cols if col in df_combined.columns]
|
||||
df_filtered = df_combined[available_target_cols].copy()
|
||||
|
||||
# 5. Proses Kolom 'Wallet Info' dan 'Phone Number'
|
||||
if 'Wallet Info' in df_filtered.columns:
|
||||
print("Mengekstrak data pada kolom Wallet Info...")
|
||||
df_filtered['Wallet Info'] = df_filtered['Wallet Info'].apply(extract_wallet_amount)
|
||||
|
||||
if 'Phone Number' in df_filtered.columns:
|
||||
print("Membersihkan format nomor HP...")
|
||||
df_filtered['Phone Number'] = df_filtered['Phone Number'].apply(clean_phone_number)
|
||||
|
||||
# 6. Rename nama kolom Excel agar sesuai dengan nama kolom PostgreSQL
|
||||
# Hanya rename kolom yang ada di COLUMN_MAPPING
|
||||
print("Menyesuaikan nama kolom dengan tabel PostgreSQL...")
|
||||
df_final = df_filtered.rename(columns=COLUMN_MAPPING)
|
||||
|
||||
# Pastikan kita hanya mengimport kolom yang sudah di-mapping ke DB
|
||||
# Jika ada kolom kuning yang tidak ada di mapping, kita drop agar DB tidak error
|
||||
db_columns = list(COLUMN_MAPPING.values())
|
||||
cols_to_import = [col for col in df_final.columns if col in db_columns]
|
||||
df_final = df_final[cols_to_import]
|
||||
|
||||
# 7. Insert ke PostgreSQL menggunakan Multi-Threading
|
||||
print(f"\nMenyiapkan proses insert dengan {MAX_THREADS} thread...")
|
||||
try:
|
||||
# Format koneksi SQLAlchemy
|
||||
# (tambahkan parameter pool_size agar aman untuk banyak thread yang buka koneksi bersamaan)
|
||||
engine_url = f'postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}'
|
||||
engine = create_engine(engine_url, pool_size=MAX_THREADS, max_overflow=5)
|
||||
|
||||
# Memecah dataframe menjadi beberapa chunk (potongan kecil)
|
||||
chunks = [df_final[i:i + CHUNK_SIZE] for i in range(0, df_final.shape[0], CHUNK_SIZE)]
|
||||
total_chunks = len(chunks)
|
||||
print(f"Data sebanyak {len(df_final)} baris dipecah menjadi {total_chunks} chunk.")
|
||||
|
||||
# Menjalankan multi-threading menggunakan ThreadPoolExecutor
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_THREADS) as executor:
|
||||
# Submit semua task (chunk) ke executor
|
||||
futures = {
|
||||
executor.submit(insert_data_chunk, chunk, engine, TABLE_NAME, i+1): i+1
|
||||
for i, chunk in enumerate(chunks)
|
||||
}
|
||||
|
||||
# Menunggu dan memantau hasil dari setiap thread yang selesai
|
||||
berhasil = 0
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
if future.result():
|
||||
berhasil += 1
|
||||
|
||||
print(f"\n🎉 Selesai! {berhasil} dari {total_chunks} chunk berhasil di-import ke PostgreSQL.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Terjadi kesalahan fatal saat setup database: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@ -0,0 +1,7 @@
|
||||
Flask==3.0.0
|
||||
Flask-Session==0.5.0
|
||||
psycopg2-binary==2.9.9
|
||||
Werkzeug==3.0.1
|
||||
pandas
|
||||
sqlalchemy
|
||||
openpyxl
|
||||
436
static/style.css
Normal file
436
static/style.css
Normal file
@ -0,0 +1,436 @@
|
||||
:root {
|
||||
--bg-color: #f8fafc;
|
||||
--text-color: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
--glass-bg: rgba(255, 255, 255, 0.85);
|
||||
--glass-border: rgba(255, 255, 255, 0.5);
|
||||
--glass-shadow: rgba(0, 0, 0, 0.05);
|
||||
--primary: #4f46e5;
|
||||
--primary-hover: #4338ca;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(circle at top left, #e0e7ff, #f8fafc);
|
||||
}
|
||||
|
||||
.blob {
|
||||
position: absolute;
|
||||
filter: blur(80px);
|
||||
opacity: 0.6;
|
||||
border-radius: 50%;
|
||||
animation: float 10s infinite ease-in-out alternate;
|
||||
}
|
||||
|
||||
.blob-1 {
|
||||
top: -10%;
|
||||
left: -10%;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: #c7d2fe;
|
||||
}
|
||||
|
||||
.blob-2 {
|
||||
bottom: -10%;
|
||||
right: -10%;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: #fbcfe8;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
100% { transform: translate(50px, 50px) scale(1.1); }
|
||||
}
|
||||
|
||||
/* Glass Panels */
|
||||
.glass-panel {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 30px var(--glass-shadow);
|
||||
}
|
||||
|
||||
.glass-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.nav-brand {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.nav-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: rgba(0,0,0,0.04);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 99px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--danger);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.app-container {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
margin: 0.5rem 0 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Table Area */
|
||||
.table-panel {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
padding: 1.5rem 2rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* DataTables customization for Light Mode */
|
||||
.dataTables_wrapper .dataTables_length,
|
||||
.dataTables_wrapper .dataTables_filter,
|
||||
.dataTables_wrapper .dataTables_info,
|
||||
.dataTables_wrapper .dataTables_processing,
|
||||
.dataTables_wrapper .dataTables_paginate {
|
||||
color: var(--text-muted) !important;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dataTables_wrapper .dataTables_filter input {
|
||||
background: #ffffff;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
color: var(--text-color);
|
||||
padding: 0.5rem 1rem;
|
||||
outline: none;
|
||||
margin-left: 0.5rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.dataTables_wrapper .dataTables_filter input:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.dataTables_wrapper .dataTables_length select {
|
||||
background: #ffffff;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
color: var(--text-color);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
table.dataTable {
|
||||
border-collapse: collapse !important;
|
||||
width: 100% !important;
|
||||
color: var(--text-color);
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
table.dataTable thead th {
|
||||
border-bottom: 1px solid #e2e8f0 !important;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
padding: 1rem 0.5rem !important;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
table.dataTable tbody tr {
|
||||
background-color: transparent !important;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
table.dataTable tbody tr:hover {
|
||||
background-color: rgba(0,0,0,0.02) !important;
|
||||
}
|
||||
|
||||
table.dataTable tbody td {
|
||||
border-bottom: 1px solid #f1f5f9 !important;
|
||||
padding: 1rem 0.5rem !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.dataTables_wrapper .dataTables_paginate .paginate_button {
|
||||
color: var(--text-muted) !important;
|
||||
border: 1px solid transparent !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.dataTables_wrapper .dataTables_paginate .paginate_button.current,
|
||||
.dataTables_wrapper .dataTables_paginate .paginate_button.current:hover {
|
||||
background: var(--primary) !important;
|
||||
color: white !important;
|
||||
border: 1px solid var(--primary) !important;
|
||||
}
|
||||
|
||||
.dataTables_wrapper .dataTables_paginate .paginate_button:hover {
|
||||
background: rgba(0,0,0,0.04) !important;
|
||||
color: var(--text-color) !important;
|
||||
border: 1px solid transparent !important;
|
||||
}
|
||||
|
||||
/* Buttons and Badges */
|
||||
.btn-migrasi {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-migrasi:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
.btn-migrasi.disabled {
|
||||
background: #e2e8f0;
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 99px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
/* Login Page */
|
||||
.login-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
color: var(--primary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.login-panel .title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.75rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.login-panel .subtitle {
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 2rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
text-align: left;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border: 1px solid #cbd5e1;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
color: var(--text-color);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.875rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* Responsive Adjustments for HP/Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.glass-nav {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.nav-user {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.dataTables_wrapper .dataTables_filter {
|
||||
text-align: left !important;
|
||||
margin-top: 1rem;
|
||||
float: none;
|
||||
}
|
||||
|
||||
.dataTables_wrapper .dataTables_filter input {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
margin-top: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dataTables_wrapper .dataTables_length {
|
||||
float: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.btn-migrasi {
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
181
templates/index.html
Normal file
181
templates/index.html
Normal file
@ -0,0 +1,181 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="glass-nav">
|
||||
<div class="nav-brand">
|
||||
<svg width="24" height="24" 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>
|
||||
Tada to Odoo Migration
|
||||
</div>
|
||||
<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 }}
|
||||
</span>
|
||||
<a href="{{ url_for('logout') }}" class="btn-logout">
|
||||
Log out
|
||||
<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="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div class="glass-panel table-panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Customer Database</h2>
|
||||
<p class="panel-subtitle">Review and migrate customer profiles to Odoo 19.</p>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table id="customersTable" class="display" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Phone</th>
|
||||
<th>Email</th>
|
||||
<th>Level</th>
|
||||
<th>Points</th>
|
||||
<th>Migrated By</th>
|
||||
<th class="action-col">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Populated by DataTables via AJAX -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
var table = $('#customersTable').DataTable({
|
||||
"processing": true,
|
||||
"serverSide": true,
|
||||
"ajax": {
|
||||
"url": "{{ url_for('get_customers') }}",
|
||||
"type": "GET"
|
||||
},
|
||||
"columns": [
|
||||
{ "data": "id" },
|
||||
{ "data": "name" },
|
||||
{ "data": "phone_number", "defaultContent": "-" },
|
||||
{ "data": "email", "defaultContent": "-" },
|
||||
{ "data": "level", "defaultContent": "-" },
|
||||
{ "data": "point_amount", "defaultContent": "0" },
|
||||
{
|
||||
"data": "responsible",
|
||||
"render": function(data, type, row) {
|
||||
if(data) {
|
||||
return `<span class="badge badge-success">Done by ${data}</span>`;
|
||||
}
|
||||
return `<span class="badge badge-warning">Pending</span>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": null,
|
||||
"orderable": false,
|
||||
"render": function(data, type, row) {
|
||||
if(row.responsible) {
|
||||
return `<button class="btn-migrasi disabled" disabled>Migrated</button>`;
|
||||
}
|
||||
let safeName = row.name ? row.name.replace(/'/g, "\\'") : 'Unknown Customer';
|
||||
return `<button class="btn-migrasi" onclick="migrateCustomer(${row.id}, '${safeName}')">Migrasi Data</button>`;
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageLength": 10,
|
||||
"language": {
|
||||
"search": "Search records:",
|
||||
"searchPlaceholder": "Name, Email, Phone (min 3 chars)"
|
||||
}
|
||||
});
|
||||
|
||||
// Custom search logic: debounce 400ms and min 3 characters
|
||||
var searchTimeout = null;
|
||||
$('.dataTables_filter input').unbind().bind('input', function() {
|
||||
var searchTerm = this.value;
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(function() {
|
||||
if (searchTerm.length >= 3 || searchTerm.length === 0) {
|
||||
table.search(searchTerm).draw();
|
||||
}
|
||||
}, 400);
|
||||
});
|
||||
|
||||
window.migrateCustomer = function(id, name) {
|
||||
Swal.fire({
|
||||
title: 'Confirm Migration',
|
||||
text: `Are you sure you want to migrate "${name}" to Odoo 19?`,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#6366f1',
|
||||
cancelButtonColor: '#334155',
|
||||
confirmButtonText: 'Yes, migrate now!',
|
||||
background: '#1e293b',
|
||||
color: '#f8fafc',
|
||||
customClass: {
|
||||
popup: 'glass-popup'
|
||||
}
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
// Show loading
|
||||
Swal.fire({
|
||||
title: 'Migrating...',
|
||||
text: 'Please wait while data is being transferred.',
|
||||
allowOutsideClick: false,
|
||||
background: '#1e293b',
|
||||
color: '#f8fafc',
|
||||
didOpen: () => {
|
||||
Swal.showLoading();
|
||||
}
|
||||
});
|
||||
|
||||
// AJAX Request
|
||||
$.ajax({
|
||||
url: `/api/migrate/${id}`,
|
||||
type: 'POST',
|
||||
success: function(response) {
|
||||
if(response.success) {
|
||||
Swal.fire({
|
||||
title: 'Success!',
|
||||
text: response.message,
|
||||
icon: 'success',
|
||||
background: '#1e293b',
|
||||
color: '#f8fafc',
|
||||
confirmButtonColor: '#10b981'
|
||||
});
|
||||
table.draw(false); // Reload table data without resetting pagination
|
||||
} else {
|
||||
Swal.fire({
|
||||
title: 'Failed!',
|
||||
text: response.message,
|
||||
icon: 'error',
|
||||
background: '#1e293b',
|
||||
color: '#f8fafc',
|
||||
confirmButtonColor: '#ef4444'
|
||||
});
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
let msg = "An unexpected error occurred.";
|
||||
if(xhr.responseJSON && xhr.responseJSON.message) {
|
||||
msg = xhr.responseJSON.message;
|
||||
}
|
||||
Swal.fire({
|
||||
title: 'Error!',
|
||||
text: msg,
|
||||
icon: 'error',
|
||||
background: '#1e293b',
|
||||
color: '#f8fafc',
|
||||
confirmButtonColor: '#ef4444'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
34
templates/layout.html
Normal file
34
templates/layout.html
Normal file
@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tada Migration Portal</title>
|
||||
<!-- Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- DataTables -->
|
||||
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.min.css">
|
||||
<!-- SweetAlert2 -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.min.css">
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-background">
|
||||
<!-- Animated background elements can go here via CSS -->
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="app-container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
21
templates/login.html
Normal file
21
templates/login.html
Normal file
@ -0,0 +1,21 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="login-wrapper">
|
||||
<div class="glass-panel login-panel">
|
||||
<div class="brand-logo">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loading…
Reference in New Issue
Block a user