Created Cally
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
|
||||
# Separate database for Cally to isolate ServiceM8 data
|
||||
DB_PATH = os.getenv("CALLY_DB_PATH", "./cally.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_cally_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from .cally_database import Base
|
||||
|
||||
class Sm8Staff(Base):
|
||||
__tablename__ = "sm8_staff"
|
||||
uuid = Column(String, primary_key=True)
|
||||
name = Column(String, nullable=False)
|
||||
active = Column(Boolean, default=True)
|
||||
|
||||
class Sm8Job(Base):
|
||||
__tablename__ = "sm8_jobs"
|
||||
uuid = Column(String, primary_key=True)
|
||||
job_id = Column(String, nullable=False) # e.g. #1234
|
||||
address = Column(String)
|
||||
|
||||
class ScheduleDay(Base):
|
||||
__tablename__ = "schedule_days"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
staff_uuid = Column(String, nullable=False)
|
||||
date = Column(Date, nullable=False)
|
||||
is_busy = Column(Boolean, default=False)
|
||||
job_count = Column(Integer, default=0)
|
||||
job_uuids = Column(String) # Comma-separated list of job UUIDs
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
+50
-1
@@ -7,11 +7,14 @@ from typing import List, Dict
|
||||
import uuid
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import date as date_type
|
||||
|
||||
from . import models, schemas, auth, database
|
||||
from . import models, schemas, auth, database, cally_models, cally_database, servicem8
|
||||
from .database import engine, get_db
|
||||
from .cally_database import engine as cally_engine, get_cally_db
|
||||
|
||||
models.Base.metadata.create_all(bind=engine)
|
||||
cally_models.Base.metadata.create_all(bind=cally_engine)
|
||||
|
||||
class EventNotifier:
|
||||
def __init__(self):
|
||||
@@ -310,6 +313,52 @@ async def update_workspace(ws_update: schemas.WorkspaceUpdate, db: Session = Dep
|
||||
await manager.broadcast(json.dumps({"type": "refresh"}))
|
||||
return ws
|
||||
|
||||
@app.get("/calendar/schedule", response_model=schemas.CalendarScheduleResponse)
|
||||
def get_calendar_schedule(
|
||||
start_date: date_type,
|
||||
end_date: date_type,
|
||||
db: Session = Depends(get_cally_db),
|
||||
current_user: models.User = Depends(auth.get_current_user)
|
||||
):
|
||||
try:
|
||||
staff = db.query(cally_models.Sm8Staff).filter(cally_models.Sm8Staff.active == True).all()
|
||||
|
||||
# If no staff, trigger an initial sync
|
||||
if not staff:
|
||||
servicem8.sync_from_servicem8(db)
|
||||
staff = db.query(cally_models.Sm8Staff).filter(cally_models.Sm8Staff.active == True).all()
|
||||
|
||||
schedule = db.query(cally_models.ScheduleDay).filter(
|
||||
cally_models.ScheduleDay.date >= start_date,
|
||||
cally_models.ScheduleDay.date <= end_date
|
||||
).all()
|
||||
|
||||
# Collect all job UUIDs from the schedule to fetch their details
|
||||
job_uuids = set()
|
||||
for s in schedule:
|
||||
if s.job_uuids:
|
||||
job_uuids.update(s.job_uuids.split(','))
|
||||
|
||||
jobs = db.query(cally_models.Sm8Job).filter(cally_models.Sm8Job.uuid.in_(list(job_uuids))).all()
|
||||
|
||||
return {"staff": staff, "schedule": schedule, "jobs": jobs}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/calendar/sync")
|
||||
async def sync_calendar(
|
||||
db: Session = Depends(get_cally_db),
|
||||
current_user: models.User = Depends(auth.get_current_user)
|
||||
):
|
||||
success = servicem8.sync_from_servicem8(db)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to sync from ServiceM8")
|
||||
|
||||
await manager.broadcast(json.dumps({"type": "refresh"}))
|
||||
return {"message": "Sync successful"}
|
||||
|
||||
@app.get("/audit", response_model=List[schemas.AuditLog])
|
||||
def read_audit(db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)):
|
||||
return db.query(models.AuditLog).order_by(models.AuditLog.at.desc()).all()
|
||||
|
||||
@@ -7,3 +7,4 @@ python-jose[cryptography]
|
||||
passlib
|
||||
bcrypt==4.0.1
|
||||
python-multipart
|
||||
requests
|
||||
|
||||
+36
-1
@@ -1,6 +1,6 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from datetime import datetime, date as date_type
|
||||
|
||||
class UserBase(BaseModel):
|
||||
id: str
|
||||
@@ -136,3 +136,38 @@ class Token(BaseModel):
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: Optional[str] = None
|
||||
|
||||
# Cally / ServiceM8 Schemas
|
||||
class Sm8StaffBase(BaseModel):
|
||||
uuid: str
|
||||
name: str
|
||||
active: bool = True
|
||||
|
||||
class Sm8Staff(Sm8StaffBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class Sm8Job(BaseModel):
|
||||
uuid: str
|
||||
job_id: str
|
||||
address: Optional[str] = None
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ScheduleDayBase(BaseModel):
|
||||
staff_uuid: str
|
||||
date: date_type
|
||||
is_busy: bool
|
||||
job_count: int = 0
|
||||
job_uuids: Optional[str] = None
|
||||
|
||||
class ScheduleDay(ScheduleDayBase):
|
||||
id: int
|
||||
updated_at: datetime
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class CalendarScheduleResponse(BaseModel):
|
||||
staff: List[Sm8Staff]
|
||||
schedule: List[ScheduleDay]
|
||||
jobs: List[Sm8Job]
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import requests
|
||||
import os
|
||||
from datetime import datetime, date, timedelta
|
||||
from sqlalchemy.orm import Session
|
||||
from . import cally_models
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
SM8_TOKEN = os.getenv("SERVICEM8_ACCESS_TOKEN")
|
||||
BASE_URL = "https://api.servicem8.com/api_1.0"
|
||||
|
||||
def get_sm8_headers():
|
||||
return {
|
||||
"X-Api-Key": SM8_TOKEN,
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def sync_from_servicem8(db: Session):
|
||||
headers = get_sm8_headers()
|
||||
|
||||
# 1. Sync Staff
|
||||
staff_resp = requests.get(f"{BASE_URL}/staff.json", headers=headers)
|
||||
if staff_resp.status_code == 200:
|
||||
staff_list = staff_resp.json()
|
||||
for s in staff_list:
|
||||
# We only care about active staff who are shown on the schedule
|
||||
if s.get('active') == 1 and s.get('hide_from_schedule') == 0:
|
||||
first = s.get('first', '')
|
||||
last = s.get('last', '')
|
||||
db_staff = cally_models.Sm8Staff(
|
||||
uuid=s.get('uuid'),
|
||||
name=f"{first} {last}".strip() or "Unknown Staff",
|
||||
active=True
|
||||
)
|
||||
db.merge(db_staff)
|
||||
db.commit()
|
||||
|
||||
# 2. Sync Schedule (Fetch last 30 days and next 60 days)
|
||||
start_date = (date.today() - timedelta(days=31)).isoformat()
|
||||
# ServiceM8 API v1.0 OData does not support 'ge', but supports 'gt'
|
||||
# We use 'and' to combine filters
|
||||
params = {
|
||||
"$filter": f"activity_was_scheduled eq 1 and start_date gt '{start_date} 00:00:00'"
|
||||
}
|
||||
|
||||
activity_resp = requests.get(f"{BASE_URL}/jobactivity.json", headers=headers, params=params)
|
||||
if activity_resp.status_code == 200:
|
||||
activities = activity_resp.json()
|
||||
|
||||
# Clear existing cached schedule in the range to avoid duplicates
|
||||
db.query(cally_models.ScheduleDay).delete()
|
||||
|
||||
# Track busy days: {staff_uuid: {date_str: [job_uuids]}}
|
||||
busy_map = {}
|
||||
unique_job_uuids = set()
|
||||
|
||||
for a in activities:
|
||||
staff_uuid = a.get('staff_uuid')
|
||||
start_dt_str = a.get('start_date')
|
||||
job_uuid = a.get('job_uuid')
|
||||
if not staff_uuid or not start_dt_str or not job_uuid:
|
||||
continue
|
||||
|
||||
# Extract date from 'YYYY-MM-DD HH:MM:SS'
|
||||
activity_date_str = start_dt_str.split(' ')[0]
|
||||
|
||||
if staff_uuid not in busy_map:
|
||||
busy_map[staff_uuid] = {}
|
||||
|
||||
if activity_date_str not in busy_map[staff_uuid]:
|
||||
busy_map[staff_uuid][activity_date_str] = []
|
||||
|
||||
if job_uuid not in busy_map[staff_uuid][activity_date_str]:
|
||||
busy_map[staff_uuid][activity_date_str].append(job_uuid)
|
||||
unique_job_uuids.add(job_uuid)
|
||||
|
||||
# 3. Sync Job Details for found activities
|
||||
# We need human-readable IDs (#123) from the 'job' table
|
||||
for job_uuid in unique_job_uuids:
|
||||
# Check if we already have this job detail
|
||||
existing_job = db.query(cally_models.Sm8Job).filter(cally_models.Sm8Job.uuid == job_uuid).first()
|
||||
if not existing_job:
|
||||
job_resp = requests.get(f"{BASE_URL}/job/{job_uuid}.json", headers=headers)
|
||||
if job_resp.status_code == 200:
|
||||
j = job_resp.json()
|
||||
db_job = cally_models.Sm8Job(
|
||||
uuid=job_uuid,
|
||||
job_id=j.get('generated_job_id', 'Unknown'),
|
||||
address=j.get('job_address', '')
|
||||
)
|
||||
db.add(db_job)
|
||||
db.commit()
|
||||
|
||||
# 4. Save Schedule to DB
|
||||
for staff_uuid, dates_dict in busy_map.items():
|
||||
for d_str, job_list in dates_dict.items():
|
||||
d_obj = date.fromisoformat(d_str)
|
||||
db_day = cally_models.ScheduleDay(
|
||||
staff_uuid=staff_uuid,
|
||||
date=d_obj,
|
||||
is_busy=True,
|
||||
job_count=len(job_list),
|
||||
job_uuids=",".join(job_list)
|
||||
)
|
||||
db.add(db_day)
|
||||
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
return False
|
||||
Reference in New Issue
Block a user