Wave 2: form cleanups from the site comments, plus localization, project switcher and global search
Site comments (8/3) - BIM card: LOD removed, IFF # added next to the coordination status, and required once that status is "Signed off (IFF)" — an unnumbered sign-off isn't traceable. A LOD already stored on a package is preserved and shown as legacy, not blanked. - The blue "from SOP types" subtext under a field is now a SOP chip on the label with the detail in a tooltip. The chip stays visible rather than hover-only: field tablets have no hover, and "this came from the SOP" is the part that matters. The hint elements stay in the DOM (hidden) so the code writing to them keeps working; an observer mirrors their text into the tooltip. - Specification Section is no longer typed per package. Each WP type carries a spec section on the SOP; the field is read-only in the Creator and follows the type, with the SOP's spec folder linked underneath. This reads both spec comments as one intent — stop typing it, derive it. - Assignees and Distribution are multi-selects over the SOP project team, showing each person's job function, with the CM pre-added to Distribution (removable per package) and a free-text option for people with no account. The stored display strings are unchanged so print/export/dashboard keep working; account ids ride alongside for the notification work in wave 3. Localization + time - Per-user locale/timezone (Language & time in the user menu), an app-wide default in the admin console, then the browser. Timezones are validated against the server's zoneinfo and the picker is fed from it. Calendar dates are formatted from their parts so a due date never reads a day early in another zone. - Every displayed timestamp now goes through the shared helpers. Top-bar chrome - Project switcher beside the logo and a centered global search, injected into either generation of top bar; skipped in an iframe so the embedded Creator doesn't get a second one. Ctrl/Cmd-K focuses search. - GET /api/search covers work packages, projects and SOPs, scoped to the caller's projects, hiding archived packages, with LIKE wildcards escaped. Fixed along the way: showForm() cleared every card's inline display, which undid applyKind() — so the Package Type and BIM cards reappeared on an install-only project. Split out applyKindVisibility() and re-apply it there. Verified: 100 API checks on a fresh database (44 permissions + 22 password reset + 34 search/localization), 24 driven UI checks against the real Creator page in headless Chrome (SOP chips, both people pickers, spec auto-fill, critical tags, BIM suppression), and the chrome harness on both bar styles. Screenshots reviewed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
"""user locale + timezone preferences
|
||||
|
||||
Per-user display preferences. Empty means "use the app default (admin console),
|
||||
then the browser". Stored server-side so they follow the person between devices —
|
||||
shared field tablets are the case that matters.
|
||||
|
||||
Revision ID: c93f2b1d7e04
|
||||
Revises: b41c7ae90d52
|
||||
Create Date: 2026-08-03 16:44:10.882931
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c93f2b1d7e04'
|
||||
down_revision = 'b41c7ae90d52'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('users', sa.Column('locale', sa.String(length=20), nullable=False, server_default=''))
|
||||
op.add_column('users', sa.Column('timezone', sa.String(length=60), nullable=False, server_default=''))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('users', 'timezone')
|
||||
op.drop_column('users', 'locale')
|
||||
140
server/app.py
140
server/app.py
@@ -266,6 +266,8 @@ class SettingsIn(BaseModel):
|
||||
from_name: Optional[str] = None
|
||||
app_base_url: Optional[str] = None
|
||||
bim_enabled: Optional[bool] = None
|
||||
default_locale: Optional[str] = None
|
||||
default_timezone: Optional[str] = None
|
||||
|
||||
|
||||
class TestEmailIn(BaseModel):
|
||||
@@ -319,6 +321,13 @@ class ProjectRoleIn(BaseModel):
|
||||
project_role: str = ""
|
||||
|
||||
|
||||
class PreferencesIn(BaseModel):
|
||||
# Empty string clears the preference (fall back to the app default, then the
|
||||
# browser). None means "leave this one alone".
|
||||
locale: Optional[str] = None
|
||||
timezone: Optional[str] = None
|
||||
|
||||
|
||||
class ForgotPasswordIn(BaseModel):
|
||||
username: str = "" # username or email
|
||||
|
||||
@@ -511,6 +520,50 @@ def whoami(user: models.User = Depends(auth.get_current_user)):
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
# ── Display preferences (self-service) ─────────────────────────────────────────
|
||||
_LOCALE_RE = re.compile(r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$")
|
||||
|
||||
|
||||
def valid_timezone(tz: str) -> bool:
|
||||
"""True if this is a real IANA zone name on this machine."""
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
ZoneInfo(tz)
|
||||
return True
|
||||
except Exception: # noqa: BLE001 — unknown key, missing tzdata, bad type
|
||||
return False
|
||||
|
||||
|
||||
@app.get("/api/timezones")
|
||||
def list_timezones(_user: models.User = Depends(auth.get_current_user)):
|
||||
"""IANA zone names for the preferences picker, so the list matches what the
|
||||
server will actually accept."""
|
||||
try:
|
||||
from zoneinfo import available_timezones
|
||||
return sorted(available_timezones())
|
||||
except Exception: # noqa: BLE001 — no tzdata: let the client fall back
|
||||
return []
|
||||
|
||||
|
||||
@app.post("/api/auth/preferences")
|
||||
def set_preferences(body: PreferencesIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
"""A user's own locale / timezone. Empty string clears the preference; the app
|
||||
default (admin console) applies next, then the browser's own settings."""
|
||||
if body.locale is not None:
|
||||
loc = body.locale.strip()
|
||||
if loc and not _LOCALE_RE.match(loc):
|
||||
raise HTTPException(status_code=400, detail="Locale must be a language tag like 'en-US' or 'es'.")
|
||||
user.locale = loc[:20]
|
||||
if body.timezone is not None:
|
||||
tz = body.timezone.strip()
|
||||
if tz and not valid_timezone(tz):
|
||||
raise HTTPException(status_code=400, detail="Unknown time zone. Pick one from the list.")
|
||||
user.timezone = tz[:60]
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
@app.post("/api/auth/password")
|
||||
def change_password(body: PasswordChangeIn, request: Request, response: Response, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
if not auth.verify_password(body.current_password, user.password_hash):
|
||||
@@ -1087,6 +1140,81 @@ def list_audit(
|
||||
return [e.to_dict() for e in rows]
|
||||
|
||||
|
||||
# ── Global search ──────────────────────────────────────────────────────────────
|
||||
def _like_term(q: str) -> str:
|
||||
"""Escape LIKE wildcards so a user searching for '100%' or 'a_b' gets what they
|
||||
typed rather than a pattern. Paired with escape='\\' on the comparison."""
|
||||
return "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_").lower() + "%"
|
||||
|
||||
|
||||
@app.get("/api/search")
|
||||
def global_search(
|
||||
q: str = Query("", min_length=0, max_length=200),
|
||||
limit: int = Query(8, ge=1, le=25),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Type-ahead across projects, work packages and SOPs, scoped to the projects
|
||||
the caller may access. Matches work-package number, subject, type and status,
|
||||
project name/number/client/site, and SOP name/number."""
|
||||
term = (q or "").strip()
|
||||
if len(term) < 2:
|
||||
return {"query": term, "projects": [], "wps": [], "sops": []}
|
||||
pat = _like_term(term)
|
||||
esc = "\\"
|
||||
|
||||
proj_stmt = select(models.Project).where(
|
||||
func.lower(models.Project.name).like(pat, escape=esc)
|
||||
| func.lower(models.Project.number).like(pat, escape=esc)
|
||||
| func.lower(models.Project.client).like(pat, escape=esc)
|
||||
| func.lower(models.Project.site).like(pat, escape=esc)
|
||||
)
|
||||
proj_stmt = scope_to_access(proj_stmt, models.Project.id, db, user)
|
||||
projects = db.scalars(proj_stmt.order_by(models.Project.updated_at.desc()).limit(limit)).all()
|
||||
|
||||
wp_stmt = select(models.WorkPackage).where(
|
||||
(models.WorkPackage.archived_at.is_(None))
|
||||
& (
|
||||
func.lower(models.WorkPackage.number).like(pat, escape=esc)
|
||||
| func.lower(models.WorkPackage.subject).like(pat, escape=esc)
|
||||
| func.lower(models.WorkPackage.type).like(pat, escape=esc)
|
||||
| func.lower(models.WorkPackage.status).like(pat, escape=esc)
|
||||
)
|
||||
)
|
||||
wp_stmt = scope_to_access(wp_stmt, models.WorkPackage.project_id, db, user)
|
||||
wps = db.scalars(wp_stmt.order_by(models.WorkPackage.updated_at.desc()).limit(limit)).all()
|
||||
|
||||
sop_stmt = select(models.Sop).where(
|
||||
func.lower(models.Sop.name).like(pat, escape=esc)
|
||||
| func.lower(models.Sop.number).like(pat, escape=esc)
|
||||
)
|
||||
sop_stmt = scope_to_access(sop_stmt, models.Sop.project_id, db, user)
|
||||
sops = db.scalars(sop_stmt.order_by(models.Sop.updated_at.desc()).limit(limit)).all()
|
||||
|
||||
# Project names for the WP/SOP rows, so a result reads unambiguously when the
|
||||
# same WP number exists on two jobs.
|
||||
pids = {w.project_id for w in wps} | {s.project_id for s in sops}
|
||||
pids.discard(None)
|
||||
names = {}
|
||||
if pids:
|
||||
for p in db.scalars(select(models.Project).where(models.Project.id.in_(pids))).all():
|
||||
names[p.id] = p.name or p.number or p.id
|
||||
|
||||
return {
|
||||
"query": term,
|
||||
"projects": [{"id": p.id, "name": p.name, "number": p.number, "client": p.client} for p in projects],
|
||||
"wps": [{
|
||||
"id": w.id, "number": w.number, "subject": w.subject, "type": w.type,
|
||||
"status": w.status, "project_id": w.project_id,
|
||||
"project_name": names.get(w.project_id, ""),
|
||||
} for w in wps],
|
||||
"sops": [{
|
||||
"id": s.id, "name": s.name, "number": s.number, "complete": s.complete,
|
||||
"project_id": s.project_id, "project_name": names.get(s.project_id, ""),
|
||||
} for s in sops],
|
||||
}
|
||||
|
||||
|
||||
# ── Settings (admin) ────────────────────────────────────────────────────────────
|
||||
@app.get("/api/settings")
|
||||
def get_app_settings(_admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
@@ -1103,6 +1231,18 @@ def get_app_flags(_user: models.User = Depends(auth.get_current_user), db: Sessi
|
||||
@app.put("/api/settings")
|
||||
def put_app_settings(body: SettingsIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
patch = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
# Localization defaults are validated the same way a user's own preference is,
|
||||
# so a typo can't leave every page formatting dates against a bogus zone.
|
||||
loc = (patch.get("default_locale") or "").strip()
|
||||
if loc and not _LOCALE_RE.match(loc):
|
||||
raise HTTPException(status_code=400, detail="Default locale must be a language tag like 'en-US'.")
|
||||
tz = (patch.get("default_timezone") or "").strip()
|
||||
if tz and not valid_timezone(tz):
|
||||
raise HTTPException(status_code=400, detail="Unknown default time zone.")
|
||||
if "default_locale" in patch:
|
||||
patch["default_locale"] = loc
|
||||
if "default_timezone" in patch:
|
||||
patch["default_timezone"] = tz
|
||||
saved = notify.save_settings(db, patch)
|
||||
log_event(db, admin, "settings_updated", "settings", "notifications",
|
||||
summary="notifications", detail={"email_enabled": bool(saved.get("email_enabled"))})
|
||||
|
||||
@@ -140,6 +140,11 @@ class User(Base):
|
||||
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
|
||||
# Job function on the project — free text, offered from a suggested list.
|
||||
project_role: Mapped[str] = mapped_column(String(120), default="")
|
||||
# Display preferences. Empty means "fall back to the app default, then to the
|
||||
# browser". A stored value follows the person between devices, which matters on
|
||||
# shared field tablets where the browser locale isn't theirs.
|
||||
locale: Mapped[str] = mapped_column(String(20), default="") # BCP47, e.g. en-US
|
||||
timezone: Mapped[str] = mapped_column(String(60), default="") # IANA, e.g. America/Chicago
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
@@ -157,6 +162,7 @@ class User(Base):
|
||||
"id": self.id, "username": self.username, "email": self.email,
|
||||
"full_name": self.full_name, "role": self.role,
|
||||
"project_role": self.project_role or "", "is_active": self.is_active,
|
||||
"locale": self.locale or "", "timezone": self.timezone or "",
|
||||
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
|
||||
}
|
||||
|
||||
|
||||
@@ -37,13 +37,17 @@ DEFAULTS = {
|
||||
# with it off, the SOP creator hides the BIM section entirely and every SOP is
|
||||
# install-only, so no project can be put on the BIM path by accident.
|
||||
"bim_enabled": False,
|
||||
# Localization defaults for dates, times and numbers. Empty = use each
|
||||
# browser's own locale / timezone. A user's own preference wins over these.
|
||||
"default_locale": "", # BCP47, e.g. en-US
|
||||
"default_timezone": "", # IANA, e.g. America/Chicago
|
||||
}
|
||||
|
||||
|
||||
# Settings the app needs before anyone is signed in, or that carry no secrets and
|
||||
# are safe for any authenticated user to read (feature flags + whether
|
||||
# self-service password reset can work at all).
|
||||
PUBLIC_KEYS = ("bim_enabled",)
|
||||
# are safe for any authenticated user to read (feature flags + localization
|
||||
# defaults + whether self-service password reset can work at all).
|
||||
PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone")
|
||||
|
||||
|
||||
def get_settings(db: Session) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user