feat(ce): upgrade to v0.5.0 with contacts management, advanced settings and ui modernization

This commit is contained in:
2026-01-15 09:23:58 +00:00
parent 63243cd344
commit f1de20b5b5
16 changed files with 2794 additions and 828 deletions

View File

@@ -17,7 +17,7 @@ setup_logging()
import logging
logger = logging.getLogger(__name__)
from .database import init_db, get_db, Company, Signal, EnrichmentData, RoboticsCategory
from .database import init_db, get_db, Company, Signal, EnrichmentData, RoboticsCategory, Contact, Industry, JobRoleMapping
from .services.deduplication import Deduplicator
from .services.discovery import DiscoveryService
from .services.scraping import ScraperService
@@ -58,6 +58,33 @@ class AnalysisRequest(BaseModel):
company_id: int
force_scrape: bool = False
class ContactBase(BaseModel):
gender: str
title: str = ""
first_name: str
last_name: str
email: str
job_title: str
language: str = "De"
role: str
status: str = ""
is_primary: bool = False
class ContactCreate(ContactBase):
company_id: int
class ContactUpdate(BaseModel):
gender: Optional[str] = None
title: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
email: Optional[str] = None
job_title: Optional[str] = None
language: Optional[str] = None
role: Optional[str] = None
status: Optional[str] = None
is_primary: Optional[bool] = None
# --- Events ---
@app.on_event("startup")
def on_startup():
@@ -99,7 +126,8 @@ def list_companies(
def get_company(company_id: int, db: Session = Depends(get_db)):
company = db.query(Company).options(
joinedload(Company.signals),
joinedload(Company.enrichment_data)
joinedload(Company.enrichment_data),
joinedload(Company.contacts)
).filter(Company.id == company_id).first()
if not company:
raise HTTPException(status_code=404, detail="Company not found")
@@ -300,6 +328,230 @@ def override_impressum_url(company_id: int, url: str = Query(...), db: Session =
db.commit()
return {"status": "updated", "data": impressum_data}
# --- Contact Routes ---
@app.post("/api/contacts")
def create_contact(contact: ContactCreate, db: Session = Depends(get_db)):
"""Creates a new contact and handles primary contact logic."""
if contact.is_primary:
db.query(Contact).filter(Contact.company_id == contact.company_id).update({"is_primary": False})
db_contact = Contact(**contact.dict())
db.add(db_contact)
db.commit()
db.refresh(db_contact)
return db_contact
# --- Industry Routes ---
class IndustryCreate(BaseModel):
name: str
description: Optional[str] = None
is_focus: bool = False
primary_category_id: Optional[int] = None
class IndustryUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
is_focus: Optional[bool] = None
primary_category_id: Optional[int] = None
@app.get("/api/industries")
def list_industries(db: Session = Depends(get_db)):
return db.query(Industry).all()
@app.post("/api/industries")
def create_industry(ind: IndustryCreate, db: Session = Depends(get_db)):
# 1. Prepare data
ind_data = ind.dict()
base_name = ind_data['name']
# 2. Check for duplicate name
existing = db.query(Industry).filter(Industry.name == base_name).first()
if existing:
# Auto-increment name if duplicated
counter = 1
while db.query(Industry).filter(Industry.name == f"{base_name} ({counter})").first():
counter += 1
ind_data['name'] = f"{base_name} ({counter})"
# 3. Create
db_ind = Industry(**ind_data)
db.add(db_ind)
db.commit()
db.refresh(db_ind)
return db_ind
@app.put("/api/industries/{id}")
def update_industry(id: int, ind: IndustryUpdate, db: Session = Depends(get_db)):
db_ind = db.query(Industry).filter(Industry.id == id).first()
if not db_ind:
raise HTTPException(404, "Industry not found")
for key, value in ind.dict(exclude_unset=True).items():
setattr(db_ind, key, value)
db.commit()
db.refresh(db_ind)
return db_ind
@app.delete("/api/industries/{id}")
def delete_industry(id: int, db: Session = Depends(get_db)):
db_ind = db.query(Industry).filter(Industry.id == id).first()
if not db_ind:
raise HTTPException(404, "Industry not found")
db.delete(db_ind)
db.commit()
return {"status": "deleted"}
# --- Job Role Mapping Routes ---
class JobRoleMappingCreate(BaseModel):
pattern: str
role: str
@app.get("/api/job_roles")
def list_job_roles(db: Session = Depends(get_db)):
return db.query(JobRoleMapping).all()
@app.post("/api/job_roles")
def create_job_role(mapping: JobRoleMappingCreate, db: Session = Depends(get_db)):
db_mapping = JobRoleMapping(**mapping.dict())
db.add(db_mapping)
db.commit()
db.refresh(db_mapping)
return db_mapping
@app.delete("/api/job_roles/{id}")
def delete_job_role(id: int, db: Session = Depends(get_db)):
db_mapping = db.query(JobRoleMapping).filter(JobRoleMapping.id == id).first()
if not db_mapping:
raise HTTPException(404, "Mapping not found")
db.delete(db_mapping)
db.commit()
return {"status": "deleted"}
@app.put("/api/contacts/{contact_id}")
def update_contact(contact_id: int, contact: ContactUpdate, db: Session = Depends(get_db)):
"""Updates an existing contact."""
db_contact = db.query(Contact).filter(Contact.id == contact_id).first()
if not db_contact:
raise HTTPException(404, "Contact not found")
update_data = contact.dict(exclude_unset=True)
if update_data.get("is_primary"):
db.query(Contact).filter(Contact.company_id == db_contact.company_id).update({"is_primary": False})
for key, value in update_data.items():
setattr(db_contact, key, value)
db.commit()
db.refresh(db_contact)
return db_contact
@app.delete("/api/contacts/{contact_id}")
def delete_contact(contact_id: int, db: Session = Depends(get_db)):
"""Deletes a contact."""
db_contact = db.query(Contact).filter(Contact.id == contact_id).first()
if not db_contact:
raise HTTPException(404, "Contact not found")
db.delete(db_contact)
db.commit()
return {"status": "deleted"}
@app.get("/api/contacts/all")
def list_all_contacts(
skip: int = 0,
limit: int = 50,
search: Optional[str] = None,
db: Session = Depends(get_db)
):
"""
Lists all contacts across all companies with pagination and search.
"""
query = db.query(Contact).join(Company)
if search:
search_term = f"%{search}%"
query = query.filter(
(Contact.first_name.ilike(search_term)) |
(Contact.last_name.ilike(search_term)) |
(Contact.email.ilike(search_term)) |
(Company.name.ilike(search_term))
)
total = query.count()
# Sort by ID desc
contacts = query.order_by(Contact.id.desc()).offset(skip).limit(limit).all()
# Enrich with Company Name for the frontend list
result = []
for c in contacts:
c_dict = {k: v for k, v in c.__dict__.items() if not k.startswith('_')}
c_dict['company_name'] = c.company.name if c.company else "Unknown"
result.append(c_dict)
return {"total": total, "items": result}
class BulkContactImportItem(BaseModel):
company_name: str
first_name: str
last_name: str
email: Optional[str] = None
job_title: Optional[str] = None
role: Optional[str] = "Operativer Entscheider"
gender: Optional[str] = "männlich"
class BulkContactImportRequest(BaseModel):
contacts: List[BulkContactImportItem]
@app.post("/api/contacts/bulk")
def bulk_import_contacts(req: BulkContactImportRequest, db: Session = Depends(get_db)):
"""
Bulk imports contacts.
Matches Company by Name (creates if missing).
Dedupes Contact by Email.
"""
logger.info(f"Starting bulk contact import: {len(req.contacts)} items")
stats = {"added": 0, "skipped": 0, "companies_created": 0}
for item in req.contacts:
if not item.company_name: continue
# 1. Find or Create Company
company = db.query(Company).filter(Company.name.ilike(item.company_name.strip())).first()
if not company:
company = Company(name=item.company_name.strip(), status="NEW")
db.add(company)
db.commit() # Commit to get ID
db.refresh(company)
stats["companies_created"] += 1
# 2. Check for Duplicate Contact (by Email)
if item.email:
exists = db.query(Contact).filter(Contact.email == item.email.strip()).first()
if exists:
stats["skipped"] += 1
continue
# 3. Create Contact
new_contact = Contact(
company_id=company.id,
first_name=item.first_name,
last_name=item.last_name,
email=item.email,
job_title=item.job_title,
role=item.role,
gender=item.gender,
status="Init" # Default status
)
db.add(new_contact)
stats["added"] += 1
db.commit()
return stats
def run_discovery_task(company_id: int):
# New Session for Background Task
from .database import SessionLocal