feat: Portal, Email Inbound, Discuss + module improvements
- Portal: /my/* routes, signup, password reset, portal user support - Email Inbound: IMAP polling (go-imap/v2), thread matching - Discuss: mail.channel, long-polling bus, DM, unread count - Cron: ir.cron runner (goroutine scheduler) - Bank Import, CSV/Excel Import - Automation (ir.actions.server) - Fetchmail service - HR Payroll model - Various fixes across account, sale, stock, purchase, crm, hr, project Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,14 +6,17 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/service"
|
||||
"odoo-go/pkg/tools"
|
||||
)
|
||||
|
||||
|
||||
var dbnamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`)
|
||||
|
||||
// isSetupNeeded checks if the current database has been initialized.
|
||||
@@ -55,6 +58,16 @@ func (s *Server) handleDatabaseCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate master password (default: "admin", configurable via ODOO_MASTER_PASSWORD env)
|
||||
masterPw := os.Getenv("ODOO_MASTER_PASSWORD")
|
||||
if masterPw == "" {
|
||||
masterPw = "admin"
|
||||
}
|
||||
if params.MasterPwd != masterPw {
|
||||
writeJSON(w, map[string]string{"error": "Invalid master password"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate
|
||||
if params.Login == "" || params.Password == "" {
|
||||
writeJSON(w, map[string]string{"error": "Email and password are required"})
|
||||
@@ -111,7 +124,10 @@ func (s *Server) handleDatabaseCreate(w http.ResponseWriter, r *http.Request) {
|
||||
domain := parts[1]
|
||||
domainParts := strings.Split(domain, ".")
|
||||
if len(domainParts) > 0 {
|
||||
companyName = strings.Title(domainParts[0])
|
||||
name := domainParts[0]
|
||||
if len(name) > 0 {
|
||||
companyName = strings.ToUpper(name[:1]) + name[1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,6 +191,195 @@ func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// postSetupDone caches the result of isPostSetupNeeded to avoid a DB query on every request.
|
||||
var postSetupDone atomic.Bool
|
||||
|
||||
// isPostSetupNeeded checks if the company still has default values (needs configuration).
|
||||
func (s *Server) isPostSetupNeeded() bool {
|
||||
if postSetupDone.Load() {
|
||||
return false
|
||||
}
|
||||
var name string
|
||||
err := s.pool.QueryRow(context.Background(),
|
||||
`SELECT COALESCE(name, '') FROM res_company WHERE id = 1`).Scan(&name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
needed := name == "" || name == "My Company" || strings.HasPrefix(name, "My ")
|
||||
if !needed {
|
||||
postSetupDone.Store(true)
|
||||
}
|
||||
return needed
|
||||
}
|
||||
|
||||
// handleSetupWizard serves the post-setup configuration wizard.
|
||||
// Shown after first login when the company has not been configured yet.
|
||||
// Mirrors: odoo/addons/base_setup/views/res_config_settings_views.xml
|
||||
func (s *Server) handleSetupWizard(w http.ResponseWriter, r *http.Request) {
|
||||
sess := GetSession(r)
|
||||
if sess == nil {
|
||||
http.Redirect(w, r, "/web/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Load current company data
|
||||
var companyName, street, city, zip, phone, email, website, vat string
|
||||
var countryID int64
|
||||
s.pool.QueryRow(context.Background(),
|
||||
`SELECT COALESCE(name,''), COALESCE(street,''), COALESCE(city,''), COALESCE(zip,''),
|
||||
COALESCE(phone,''), COALESCE(email,''), COALESCE(website,''), COALESCE(vat,''),
|
||||
COALESCE(country_id, 0)
|
||||
FROM res_company WHERE id = $1`, sess.CompanyID,
|
||||
).Scan(&companyName, &street, &city, &zip, &phone, &email, &website, &vat, &countryID)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
esc := htmlEscape
|
||||
fmt.Fprintf(w, setupWizardHTML,
|
||||
esc(companyName), esc(street), esc(city), esc(zip), esc(phone), esc(email), esc(website), esc(vat))
|
||||
}
|
||||
|
||||
// handleSetupWizardSave saves the post-setup wizard data.
|
||||
func (s *Server) handleSetupWizardSave(w http.ResponseWriter, r *http.Request) {
|
||||
sess := GetSession(r)
|
||||
if sess == nil {
|
||||
writeJSON(w, map[string]string{"error": "Not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var params struct {
|
||||
CompanyName string `json:"company_name"`
|
||||
Street string `json:"street"`
|
||||
City string `json:"city"`
|
||||
Zip string `json:"zip"`
|
||||
Phone string `json:"phone"`
|
||||
Email string `json:"email"`
|
||||
Website string `json:"website"`
|
||||
Vat string `json:"vat"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
|
||||
writeJSON(w, map[string]string{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if params.CompanyName == "" {
|
||||
writeJSON(w, map[string]string{"error": "Company name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err := s.pool.Exec(context.Background(),
|
||||
`UPDATE res_company SET name=$1, street=$2, city=$3, zip=$4, phone=$5, email=$6, website=$7, vat=$8
|
||||
WHERE id = $9`,
|
||||
params.CompanyName, params.Street, params.City, params.Zip,
|
||||
params.Phone, params.Email, params.Website, params.Vat, sess.CompanyID)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]string{"error": fmt.Sprintf("Save error: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Also update the partner linked to the company
|
||||
s.pool.Exec(context.Background(),
|
||||
`UPDATE res_partner SET name=$1, street=$2, city=$3, zip=$4, phone=$5, email=$6, website=$7, vat=$8
|
||||
WHERE id = (SELECT partner_id FROM res_company WHERE id = $9)`,
|
||||
params.CompanyName, params.Street, params.City, params.Zip,
|
||||
params.Phone, params.Email, params.Website, params.Vat, sess.CompanyID)
|
||||
|
||||
postSetupDone.Store(true) // Mark setup as done so we don't redirect again
|
||||
writeJSON(w, map[string]interface{}{"status": "ok", "redirect": "/odoo"})
|
||||
}
|
||||
|
||||
var setupWizardHTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Setup — Configure Your Company</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f0eeee; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
.wizard { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
width: 100%%; max-width: 560px; }
|
||||
.wizard h1 { color: #71639e; margin-bottom: 6px; font-size: 24px; }
|
||||
.wizard .subtitle { color: #666; margin-bottom: 24px; font-size: 14px; }
|
||||
.wizard label { display: block; margin-bottom: 4px; font-weight: 500; color: #555; font-size: 13px; }
|
||||
.wizard input { width: 100%%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 4px;
|
||||
font-size: 14px; margin-bottom: 14px; }
|
||||
.wizard input:focus { outline: none; border-color: #71639e; box-shadow: 0 0 0 2px rgba(113,99,158,0.2); }
|
||||
.wizard button { width: 100%%; padding: 14px; background: #71639e; color: white; border: none;
|
||||
border-radius: 4px; font-size: 16px; cursor: pointer; margin-top: 10px; }
|
||||
.wizard button:hover { background: #5f5387; }
|
||||
.wizard .skip { text-align: center; margin-top: 12px; }
|
||||
.wizard .skip a { color: #999; text-decoration: none; font-size: 13px; }
|
||||
.wizard .skip a:hover { color: #666; }
|
||||
.row { display: flex; gap: 12px; }
|
||||
.row > div { flex: 1; }
|
||||
.error { color: #dc3545; margin-bottom: 12px; display: none; text-align: center; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wizard">
|
||||
<h1>Configure Your Company</h1>
|
||||
<p class="subtitle">Set up your company information</p>
|
||||
<div id="error" class="error"></div>
|
||||
<form id="wizardForm">
|
||||
<label>Company Name *</label>
|
||||
<input type="text" id="company_name" value="%s" required/>
|
||||
|
||||
<label>Street</label>
|
||||
<input type="text" id="street" value="%s"/>
|
||||
|
||||
<div class="row">
|
||||
<div><label>City</label><input type="text" id="city" value="%s"/></div>
|
||||
<div><label>ZIP</label><input type="text" id="zip" value="%s"/></div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div><label>Phone</label><input type="tel" id="phone" value="%s"/></div>
|
||||
<div><label>Email</label><input type="email" id="email" value="%s"/></div>
|
||||
</div>
|
||||
|
||||
<label>Website</label>
|
||||
<input type="url" id="website" value="%s" placeholder="https://"/>
|
||||
|
||||
<label>Tax ID / VAT</label>
|
||||
<input type="text" id="vat" value="%s"/>
|
||||
|
||||
<button type="submit">Save & Continue</button>
|
||||
</form>
|
||||
<div class="skip"><a href="/odoo">Skip for now</a></div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('wizardForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
fetch('/web/setup/wizard/save', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
company_name: document.getElementById('company_name').value,
|
||||
street: document.getElementById('street').value,
|
||||
city: document.getElementById('city').value,
|
||||
zip: document.getElementById('zip').value,
|
||||
phone: document.getElementById('phone').value,
|
||||
email: document.getElementById('email').value,
|
||||
website: document.getElementById('website').value,
|
||||
vat: document.getElementById('vat').value
|
||||
})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(result) {
|
||||
if (result.error) {
|
||||
var el = document.getElementById('error');
|
||||
el.textContent = result.error;
|
||||
el.style.display = 'block';
|
||||
} else {
|
||||
window.location.href = result.redirect || '/odoo';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// --- Database Manager HTML ---
|
||||
// Mirrors: odoo/addons/web/static/src/public/database_manager.create_form.qweb.html
|
||||
var databaseManagerHTML = `<!DOCTYPE html>
|
||||
|
||||
Reference in New Issue
Block a user