Initial commit -- just started to factor and implement python fast API backend
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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())
|
||||
@@ -0,0 +1,9 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlalchemy
|
||||
pydantic
|
||||
pydantic-settings
|
||||
python-jose[cryptography]
|
||||
passlib
|
||||
bcrypt==4.0.1
|
||||
python-multipart
|
||||
@@ -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
@@ -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 60–80km/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!")
|
||||
Reference in New Issue
Block a user