refactor(tenancy): simplify team table + move actions to edit modal
Some checks failed
Some checks failed
Reverts the expandable sub-row design back to a clean one-row-per-member table. All per-store management now happens inside the edit modal. Table: simple 4-column layout (Member | Stores & Roles | Status | Actions) with view + edit buttons. Store badges show orange for pending stores. Edit modal enhanced with per-store cards showing: - Store name, code, and status badge (Active/Pending) - Role dropdown + Update button (for active stores) - Resend invitation button (for pending stores) - Remove from store button - "Remove from all stores" link at bottom Removed: expandedMembers, flattenedRows, toggleMemberExpand, resendStoreInvitation, resendInvitation (member-level). Added: resendForStore, removeFromStore (work inside edit modal). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ Supports both auto-scraped (digital) and manually entered (offline) contacts.
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Boolean, Column, Enum, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
@@ -30,7 +30,7 @@ class ProspectContact(Base, TimestampMixin):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
prospect_id = Column(Integer, ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
contact_type = Column(Enum(ContactType), nullable=False)
|
||||
contact_type = Column(String(20), nullable=False)
|
||||
value = Column(String(500), nullable=False)
|
||||
label = Column(String(100), nullable=True) # e.g., "info", "sales", "main"
|
||||
source_url = Column(Text, nullable=True) # Page where contact was found
|
||||
|
||||
@@ -261,22 +261,67 @@ class EnrichmentService:
|
||||
return None
|
||||
|
||||
def scrape_contacts(self, db: Session, prospect: Prospect) -> list[ProspectContact]:
|
||||
"""Scrape email and phone contacts from prospect's website."""
|
||||
"""Scrape email and phone contacts from prospect's website.
|
||||
|
||||
Uses a two-phase approach:
|
||||
1. Structured extraction from <a href="tel:..."> and <a href="mailto:..."> (high confidence)
|
||||
2. Regex fallback for emails and international phone numbers (stricter filtering)
|
||||
"""
|
||||
from urllib.parse import unquote
|
||||
|
||||
domain = prospect.domain_name
|
||||
if not domain or not prospect.has_website:
|
||||
return []
|
||||
|
||||
scheme = "https" if prospect.uses_https else "http"
|
||||
base_url = f"{scheme}://{domain}"
|
||||
paths = ["", "/contact", "/kontakt", "/impressum", "/about"]
|
||||
paths = ["", "/contact", "/kontakt", "/impressum", "/about", "/mentions-legales"]
|
||||
|
||||
email_pattern = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
|
||||
phone_pattern = re.compile(r"(?:\+352|00352)?[\s.-]?\d{2,3}[\s.-]?\d{2,3}[\s.-]?\d{2,3}")
|
||||
# Structured patterns (from <a href> tags)
|
||||
tel_pattern = re.compile(r'href=["\']tel:([^"\'>\s]+)', re.IGNORECASE)
|
||||
mailto_pattern = re.compile(r'href=["\']mailto:([^"\'>\s?]+)', re.IGNORECASE)
|
||||
|
||||
false_positive_domains = {"example.com", "email.com", "domain.com", "wordpress.org", "w3.org", "schema.org"}
|
||||
found_emails = set()
|
||||
found_phones = set()
|
||||
contacts = []
|
||||
# Regex fallback patterns
|
||||
email_regex = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
|
||||
# International phone: requires + prefix to avoid matching random digit sequences
|
||||
phone_regex = re.compile(
|
||||
r"\+\d{1,3}[\s.-]?\(?\d{1,4}\)?[\s.-]?\d{2,4}[\s.-]?\d{2,4}(?:[\s.-]?\d{2,4})?"
|
||||
)
|
||||
|
||||
false_positive_domains = {
|
||||
"example.com", "email.com", "domain.com", "wordpress.org",
|
||||
"w3.org", "schema.org", "sentry.io", "googleapis.com",
|
||||
}
|
||||
found_emails: set[str] = set()
|
||||
found_phones: set[str] = set()
|
||||
contacts: list[ProspectContact] = []
|
||||
|
||||
def _add_email(email: str, url: str, source: str) -> None:
|
||||
email = unquote(email).strip().lower()
|
||||
email_domain = email.split("@")[1] if "@" in email else ""
|
||||
if email_domain in false_positive_domains or email in found_emails:
|
||||
return
|
||||
found_emails.add(email)
|
||||
contacts.append(ProspectContact(
|
||||
prospect_id=prospect.id,
|
||||
contact_type="email",
|
||||
value=email,
|
||||
source_url=url,
|
||||
source_element=source,
|
||||
))
|
||||
|
||||
def _add_phone(phone: str, url: str, source: str) -> None:
|
||||
phone_clean = re.sub(r"[\s.()\-]", "", phone)
|
||||
if len(phone_clean) < 10 or phone_clean in found_phones:
|
||||
return
|
||||
found_phones.add(phone_clean)
|
||||
contacts.append(ProspectContact(
|
||||
prospect_id=prospect.id,
|
||||
contact_type="phone",
|
||||
value=phone_clean,
|
||||
source_url=url,
|
||||
source_element=source,
|
||||
))
|
||||
|
||||
session = requests.Session()
|
||||
session.verify = False # noqa: SEC047 passive scan, not sending sensitive data
|
||||
@@ -290,29 +335,22 @@ class EnrichmentService:
|
||||
continue
|
||||
html = response.text
|
||||
|
||||
for email in email_pattern.findall(html):
|
||||
email_domain = email.split("@")[1].lower()
|
||||
if email_domain not in false_positive_domains and email not in found_emails:
|
||||
found_emails.add(email)
|
||||
contacts.append(ProspectContact(
|
||||
prospect_id=prospect.id,
|
||||
contact_type="email",
|
||||
value=email.lower(),
|
||||
source_url=url,
|
||||
source_element="regex",
|
||||
))
|
||||
# Phase 1: structured extraction from href attributes
|
||||
for phone in tel_pattern.findall(html):
|
||||
_add_phone(unquote(phone), url, "tel_href")
|
||||
|
||||
for email in mailto_pattern.findall(html):
|
||||
_add_email(email, url, "mailto_href")
|
||||
|
||||
# Phase 2: regex fallback — strip SVG/script content first
|
||||
text_html = re.sub(r"<(svg|script|style)[^>]*>.*?</\1>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
||||
|
||||
for email in email_regex.findall(text_html):
|
||||
_add_email(email, url, "regex")
|
||||
|
||||
for phone in phone_regex.findall(text_html):
|
||||
_add_phone(phone, url, "regex")
|
||||
|
||||
for phone in phone_pattern.findall(html):
|
||||
phone_clean = re.sub(r"[\s.-]", "", phone)
|
||||
if len(phone_clean) >= 8 and phone_clean not in found_phones:
|
||||
found_phones.add(phone_clean)
|
||||
contacts.append(ProspectContact(
|
||||
prospect_id=prospect.id,
|
||||
contact_type="phone",
|
||||
value=phone_clean,
|
||||
source_url=url,
|
||||
source_element="regex",
|
||||
))
|
||||
except Exception as e: # noqa: EXC003
|
||||
logger.debug("Contact scrape failed for %s%s: %s", domain, path, e)
|
||||
|
||||
@@ -321,21 +359,20 @@ class EnrichmentService:
|
||||
# Save contacts (replace existing auto-scraped ones)
|
||||
db.query(ProspectContact).filter(
|
||||
ProspectContact.prospect_id == prospect.id,
|
||||
ProspectContact.source_element == "regex",
|
||||
ProspectContact.source_element.in_(["regex", "tel_href", "mailto_href"]),
|
||||
).delete()
|
||||
|
||||
db.add_all(contacts)
|
||||
|
||||
# Mark first email and phone as primary
|
||||
if contacts:
|
||||
for c in contacts:
|
||||
if c.contact_type == "email":
|
||||
c.is_primary = True
|
||||
break
|
||||
for c in contacts:
|
||||
if c.contact_type == "phone":
|
||||
c.is_primary = True
|
||||
break
|
||||
for c in contacts:
|
||||
if c.contact_type == "email":
|
||||
c.is_primary = True
|
||||
break
|
||||
for c in contacts:
|
||||
if c.contact_type == "phone":
|
||||
c.is_primary = True
|
||||
break
|
||||
|
||||
prospect.last_contact_scrape_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
|
||||
Reference in New Issue
Block a user