Initial commit -- just started to factor and implement python fast API backend

This commit is contained in:
2026-05-11 12:48:35 +10:00
commit b1b621bc4a
23 changed files with 4101 additions and 0 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
# SECRET_KEY should be in an environment variable in production
SECRET_KEY = "your-secret-key-change-this-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
+24
View File
@@ -0,0 +1,24 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
# Use an environment variable for the DB path, or default to local dashy.db
# On CIFS mounts, SQLite locking will fail, so we recommend a local path.
DB_PATH = os.getenv("DASHY_DB_PATH", "./dashy.db")
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_PATH}"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False, "timeout": 30}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+101
View File
@@ -0,0 +1,101 @@
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from typing import List
import uuid
from . import models, schemas, auth, database
from .database import engine, get_db
models.Base.metadata.create_all(bind=engine)
app = FastAPI(title="Dashy API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, specify your frontend URL
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.post("/token", response_model=schemas.Token)
async def login_for_access_token(form_data: schemas.UserCreate, db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.id == form_data.id).first()
if not user or not auth.verify_password(form_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = auth.create_access_token(data={"sub": user.id})
return {"access_token": access_token, "token_type": "bearer"}
@app.get("/users", response_model=List[schemas.User])
def read_users(db: Session = Depends(get_db)):
return db.query(models.User).all()
@app.get("/tasks", response_model=List[schemas.Task])
def read_tasks(db: Session = Depends(get_db)):
return db.query(models.Task).all()
@app.post("/tasks", response_model=schemas.Task)
def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)):
task_id = task.id or f"t_{uuid.uuid4().hex[:8]}"
db_task = models.Task(
id=task_id,
title=task.title,
description=task.description,
assignee_id=task.assignee_id,
added_by=task.added_by,
priority=task.priority,
source=task.source,
status=task.status,
due_at=task.due_at,
reminder_at=task.reminder_at
)
db.add(db_task)
for tag_name in task.tags:
tag = db.query(models.Tag).filter(models.Tag.tag == tag_name).first()
if not tag:
tag = models.Tag(tag=tag_name)
db.add(tag)
db_task.tags.append(tag)
db.commit()
db.refresh(db_task)
return db_task
@app.patch("/tasks/{task_id}", response_model=schemas.Task)
def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Depends(get_db)):
db_task = db.query(models.Task).filter(models.Task.id == task_id).first()
if not db_task:
raise HTTPException(status_code=404, detail="Task not found")
update_data = task_update.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(db_task, key, value)
db.commit()
db.refresh(db_task)
return db_task
@app.get("/audit", response_model=List[schemas.AuditLog])
def read_audit(db: Session = Depends(get_db)):
return db.query(models.AuditLog).order_by(models.AuditLog.at.desc()).all()
@app.post("/audit", response_model=schemas.AuditLog)
def create_audit(audit: schemas.AuditLogBase, db: Session = Depends(get_db)):
audit_id = f"a_{uuid.uuid4().hex[:8]}"
db_audit = models.AuditLog(
id=audit_id,
actor=audit.actor,
action=audit.action,
summary=audit.summary,
target=audit.target
)
db.add(db_audit)
db.commit()
db.refresh(db_audit)
return db_audit
+84
View File
@@ -0,0 +1,84 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Table, DateTime, Enum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from .database import Base
task_tags = Table(
"task_tags",
Base.metadata,
Column("task_id", String, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
Column("tag", String, ForeignKey("tags.tag", ondelete="CASCADE"), primary_key=True),
)
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True)
name = Column(String, nullable=False)
role = Column(String, nullable=False)
hue = Column(Integer, nullable=False)
initials = Column(String, nullable=False)
email = Column(String)
phone = Column(String)
photo = Column(String)
password_hash = Column(String)
account_type = Column(String, nullable=False, default="standard")
created_at = Column(DateTime(timezone=True), server_default=func.now())
tasks = relationship("Task", back_populates="assignee", foreign_keys="Task.assignee_id")
notes = relationship("TaskNote", back_populates="author")
class Task(Base):
__tablename__ = "tasks"
id = Column(String, primary_key=True)
title = Column(String, nullable=False)
description = Column(String)
assignee_id = Column(String, ForeignKey("users.id"), nullable=False)
added_by = Column(String, nullable=False)
priority = Column(String, nullable=False) # low, med, high
source = Column(String, nullable=False) # manual, imessage, email, automation
status = Column(String, nullable=False, default="open")
added_at = Column(DateTime(timezone=True), server_default=func.now())
due_at = Column(DateTime(timezone=True))
reminder_at = Column(DateTime(timezone=True))
assignee = relationship("User", back_populates="tasks", foreign_keys=[assignee_id])
tags = relationship("Tag", secondary=task_tags, back_populates="tasks")
notes = relationship("TaskNote", back_populates="task", cascade="all, delete")
class Tag(Base):
__tablename__ = "tags"
tag = Column(String, primary_key=True)
tasks = relationship("Task", secondary=task_tags, back_populates="tags")
class TaskNote(Base):
__tablename__ = "task_notes"
id = Column(Integer, primary_key=True, index=True)
task_id = Column(String, ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False)
author_id = Column(String, ForeignKey("users.id"), nullable=False)
body = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
task = relationship("Task", back_populates="notes")
author = relationship("User", back_populates="notes")
class AuditLog(Base):
__tablename__ = "audit_log"
id = Column(String, primary_key=True)
at = Column(DateTime(timezone=True), server_default=func.now())
actor = Column(String, nullable=False)
action = Column(String, nullable=False)
summary = Column(String, nullable=False)
target = Column(String)
class Session(Base):
__tablename__ = "sessions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
device = Column(String, nullable=False)
location = Column(String)
last_active = Column(DateTime(timezone=True), server_default=func.now())
+9
View File
@@ -0,0 +1,9 @@
fastapi
uvicorn[standard]
sqlalchemy
pydantic
pydantic-settings
python-jose[cryptography]
passlib
bcrypt==4.0.1
python-multipart
+106
View File
@@ -0,0 +1,106 @@
from pydantic import BaseModel, EmailStr
from typing import List, Optional
from datetime import datetime
class UserBase(BaseModel):
id: str
name: str
role: str
hue: int
initials: str
email: Optional[str] = None
phone: Optional[str] = None
photo: Optional[str] = None
account_type: str = "standard"
class UserCreate(UserBase):
password: str
class User(UserBase):
created_at: datetime
class Config:
from_attributes = True
class TaskNoteBase(BaseModel):
body: str
class TaskNoteCreate(TaskNoteBase):
task_id: str
author_id: str
class TaskNote(TaskNoteBase):
id: int
task_id: str
author_id: str
created_at: datetime
class Config:
from_attributes = True
class TagBase(BaseModel):
tag: str
class Tag(TagBase):
class Config:
from_attributes = True
class TaskBase(BaseModel):
title: str
description: Optional[str] = None
assignee_id: str
added_by: str
priority: str
source: str
status: str = "open"
due_at: Optional[datetime] = None
reminder_at: Optional[datetime] = None
class TaskCreate(TaskBase):
id: Optional[str] = None
tags: List[str] = []
class TaskUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
assignee_id: Optional[str] = None
priority: Optional[str] = None
status: Optional[str] = None
due_at: Optional[datetime] = None
reminder_at: Optional[datetime] = None
class Task(TaskBase):
id: str
added_at: datetime
tags: List[Tag] = []
notes: List[TaskNote] = []
class Config:
from_attributes = True
class AuditLogBase(BaseModel):
actor: str
action: str
summary: str
target: Optional[str] = None
class AuditLog(AuditLogBase):
id: str
at: datetime
class Config:
from_attributes = True
class SessionBase(BaseModel):
user_id: str
device: str
location: Optional[str] = None
class Session(SessionBase):
id: int
last_active: datetime
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None
+150
View File
@@ -0,0 +1,150 @@
from sqlalchemy.orm import Session
from .database import SessionLocal, engine
from . import models, auth
from datetime import datetime
# Import data directly as if it were coming from data.jsx
USERS = [
{"id": "rod", "name": "Rod", "role": "Owner", "hue": 220, "initials": "R"},
{"id": "lani", "name": "Lani", "role": "Admin", "hue": 340, "initials": "L"},
{"id": "kirra", "name": "Kirra", "role": "Technician", "hue": 160, "initials": "K"},
{"id": "ayron", "name": "Ayron", "role": "Technician", "hue": 40, "initials": "A"},
]
SEED_TASKS = [
{ "id": 't1', "title": 'Call back Mrs. Patel re: Hilux service quote',
"description": 'She left a voicemail Tuesday — wants confirmation on the timing belt price before she books in.',
"assignee": 'lani', "addedBy": 'rod', "priority": 'high', "source": 'imessage',
"addedAt": '2026-05-08T08:42:00', "status": 'open',
"tags": ['quote'] },
{ "id": 't2', "title": 'Email #3814 → Workorder #2207',
"description": 'Auto-converted from inbox. Tell customer ETA of Friday.',
"assignee": 'lani', "addedBy": 'system', "priority": 'med', "source": 'email',
"addedAt": '2026-05-08T07:15:00', "status": 'open',
"tags": ['WO #2207'] },
{ "id": 't3', "title": 'Reorder coolant — 5L × 4',
"description": 'Stock card ran red on the morning sweep.',
"assignee": 'lani', "addedBy": 'kirra', "priority": 'low', "source": 'manual',
"addedAt": '2026-05-07T16:02:00', "status": 'open', "tags": [] },
{ "id": 't4', "title": 'Form response from "K. Wynne" auto-switched to Billing',
"description": 'Originally captured as Service Booking — heads up.',
"assignee": 'lani', "addedBy": 'system', "priority": 'med', "source": 'automation',
"addedAt": '2026-05-08T09:50:00', "status": 'billing',
"tags": ['form'] },
{ "id": 't5', "title": 'Diagnose intermittent misfire — Camry, WO #2199',
"description": 'Cust says it stutters between 6080km/h once warm.',
"assignee": 'kirra', "addedBy": 'rod', "priority": 'high', "source": 'manual',
"addedAt": '2026-05-08T07:50:00', "status": 'open',
"tags": ['WO #2199'] },
{ "id": 't6', "title": 'Sign off on Ayron\'s brake job — Forester',
"description": 'Final torque check + road test before pickup at 3pm.',
"assignee": 'kirra', "addedBy": 'ayron', "priority": 'med', "source": 'manual',
"addedAt": '2026-05-08T09:05:00', "status": 'open', "tags": [] },
{ "id": 't7', "title": 'WO #2188 auto-marked Unsuccessful',
"description": 'Customer no-show twice — please review and decide on next step.',
"assignee": 'kirra', "addedBy": 'system', "priority": 'high', "source": 'automation',
"addedAt": '2026-05-08T06:00:00', "status": 'unsuccessful',
"tags": ['WO #2188'] },
{ "id": 't8', "title": 'Replace front pads + rotors — Forester',
"description": 'Parts arrived yesterday. Bay 2 from 10am.',
"assignee": 'ayron', "addedBy": 'kirra', "priority": 'med', "source": 'manual',
"addedAt": '2026-05-08T08:00:00', "status": 'open',
"tags": ['WO #2201'] },
{ "id": 't9', "title": 'Tidy bay 3 + sweep before lunch',
"description": '',
"assignee": 'ayron', "addedBy": 'rod', "priority": 'low', "source": 'imessage',
"addedAt": '2026-05-08T09:30:00', "status": 'open', "tags": [] },
{ "id": 't10', "title": 'Pickup parts from Repco @ 11:30',
"description": 'Two boxes for WO #2199 + an oil filter for stock.',
"assignee": 'ayron', "addedBy": 'lani', "priority": 'med', "source": 'manual',
"addedAt": '2026-05-08T08:20:00', "status": 'open', "tags": [] },
{ "id": 't11', "title": 'Approve quote on Job #2207 ($1,840)',
"description": 'Lani flagged this — needs your sign-off before sending.',
"assignee": 'rod', "addedBy": 'lani', "priority": 'high', "source": 'manual',
"addedAt": '2026-05-08T09:12:00', "status": 'open',
"tags": ['quote', 'WO #2207'] },
{ "id": 't12', "title": 'Review weekly automation report',
"description": '14 tasks created from email, 6 from iMessage, 2 form re-routes.',
"assignee": 'rod', "addedBy": 'system', "priority": 'low', "source": 'automation',
"addedAt": '2026-05-08T06:00:00', "status": 'open', "tags": [] },
]
SEED_AUDIT = [
{ "id": 'a1', "at": '2026-05-08T09:50:00', "actor": 'system', "action": 'form_rerouted',
"summary": 'Form from K. Wynne auto-switched: Service Booking → Billing form',
"target": 't4' },
{ "id": 'a2', "at": '2026-05-08T09:30:00', "actor": 'rod', "action": 'task_created',
"summary": 'Created task "Tidy bay 3 + sweep before lunch" for Ayron via iMessage',
"target": 't9' },
{ "id": 'a3', "at": '2026-05-08T09:12:00', "actor": 'lani', "action": 'task_assigned',
"summary": 'Assigned "Approve quote on Job #2207" to ROD',
"target": 't11' },
{ "id": 'a4', "at": '2026-05-08T09:05:00', "actor": 'ayron', "action": 'task_moved',
"summary": 'Moved "Sign off on brake job" from Ayron → Kirra',
"target": 't6' },
]
def seed_db():
# Create tables
models.Base.metadata.create_all(bind=engine)
db = SessionLocal()
# Add Users
for u in USERS:
is_admin = u['id'] in ['rod', 'ayron']
db_user = models.User(
id=u['id'],
name=u['name'],
role=u['role'],
hue=u['hue'],
initials=u['initials'],
email=f"{u['id']}@murchison-auto.co",
phone="+64 27 555 0184",
password_hash=auth.get_password_hash("password123"), # Default password
account_type='admin' if is_admin else 'standard'
)
db.merge(db_user)
# Add Tasks
for t in SEED_TASKS:
db_task = models.Task(
id=t['id'],
title=t['title'],
description=t['description'],
assignee_id=t['assignee'],
added_by=t['addedBy'],
priority=t['priority'],
source=t['source'],
status=t['status'],
added_at=datetime.fromisoformat(t['addedAt'])
)
db.merge(db_task)
# Add tags
for tag_name in t.get('tags', []):
tag = db.query(models.Tag).filter(models.Tag.tag == tag_name).first()
if not tag:
tag = models.Tag(tag=tag_name)
db.add(tag)
if tag not in db_task.tags:
db_task.tags.append(tag)
# Add Audit
for a in SEED_AUDIT:
db_audit = models.AuditLog(
id=a['id'],
at=datetime.fromisoformat(a['at']),
actor=a['actor'],
action=a['action'],
summary=a['summary'],
target=a['target']
)
db.merge(db_audit)
db.commit()
db.close()
if __name__ == "__main__":
seed_db()
print("Database seeded!")