sacs 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sacs/__init__.py +12 -0
- sacs/alembic.ini +40 -0
- sacs/app.py +97 -0
- sacs/audit.py +55 -0
- sacs/cli.py +791 -0
- sacs/config.py +111 -0
- sacs/db.py +116 -0
- sacs/deps.py +101 -0
- sacs/events.py +233 -0
- sacs/migrations/env.py +56 -0
- sacs/migrations/script.py.mako +24 -0
- sacs/migrations/versions/ade8928a2588_initial_schema.py +420 -0
- sacs/models.py +461 -0
- sacs/rbac.py +106 -0
- sacs/routers/__init__.py +10 -0
- sacs/routers/academics.py +199 -0
- sacs/routers/auth.py +161 -0
- sacs/routers/bell.py +208 -0
- sacs/routers/platform.py +250 -0
- sacs/routers/realtime.py +78 -0
- sacs/routers/requests.py +158 -0
- sacs/routers/schools.py +407 -0
- sacs/routers/timetable.py +237 -0
- sacs/scheduler.py +200 -0
- sacs/security.py +99 -0
- sacs/seed.py +307 -0
- sacs/workflow.py +386 -0
- sacs-0.1.0.dist-info/METADATA +223 -0
- sacs-0.1.0.dist-info/RECORD +33 -0
- sacs-0.1.0.dist-info/WHEEL +5 -0
- sacs-0.1.0.dist-info/entry_points.txt +2 -0
- sacs-0.1.0.dist-info/licenses/LICENSE +21 -0
- sacs-0.1.0.dist-info/top_level.txt +1 -0
sacs/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""SACS - Smart Academic Coordination System.
|
|
2
|
+
|
|
3
|
+
A real-time, multi-tenant school coordination platform. The package ships a
|
|
4
|
+
Python CLI (``sacs``) that launches a complete local demonstration with five
|
|
5
|
+
simultaneously usable browser workspaces backed by one shared service.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__"]
|
sacs/alembic.ini
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[alembic]
|
|
2
|
+
script_location = sacs/migrations
|
|
3
|
+
prepend_sys_path = .
|
|
4
|
+
version_path_separator = os
|
|
5
|
+
# Overridden programmatically by sacs.db; present for manual `alembic` use.
|
|
6
|
+
sqlalchemy.url = sqlite:///./_migrate_tmp.db
|
|
7
|
+
|
|
8
|
+
[loggers]
|
|
9
|
+
keys = root,sqlalchemy,alembic
|
|
10
|
+
|
|
11
|
+
[handlers]
|
|
12
|
+
keys = console
|
|
13
|
+
|
|
14
|
+
[formatters]
|
|
15
|
+
keys = generic
|
|
16
|
+
|
|
17
|
+
[logger_root]
|
|
18
|
+
level = WARNING
|
|
19
|
+
handlers = console
|
|
20
|
+
qualname =
|
|
21
|
+
|
|
22
|
+
[logger_sqlalchemy]
|
|
23
|
+
level = WARNING
|
|
24
|
+
handlers =
|
|
25
|
+
qualname = sqlalchemy.engine
|
|
26
|
+
|
|
27
|
+
[logger_alembic]
|
|
28
|
+
level = INFO
|
|
29
|
+
handlers =
|
|
30
|
+
qualname = alembic
|
|
31
|
+
|
|
32
|
+
[handler_console]
|
|
33
|
+
class = StreamHandler
|
|
34
|
+
args = (sys.stderr,)
|
|
35
|
+
level = NOTSET
|
|
36
|
+
formatter = generic
|
|
37
|
+
|
|
38
|
+
[formatter_generic]
|
|
39
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
40
|
+
datefmt = %H:%M:%S
|
sacs/app.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""FastAPI application factory for SACS."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .config import get_settings
|
|
13
|
+
from .db import get_sessionmaker
|
|
14
|
+
from .events import background_dispatcher
|
|
15
|
+
from .scheduler import bell_loop
|
|
16
|
+
|
|
17
|
+
from .routers import auth as auth_router
|
|
18
|
+
from .routers import academics as academics_router
|
|
19
|
+
from .routers import bell as bell_router
|
|
20
|
+
from .routers import platform as platform_router
|
|
21
|
+
from .routers import realtime as realtime_router
|
|
22
|
+
from .routers import requests as requests_router
|
|
23
|
+
from .routers import schools as schools_router
|
|
24
|
+
from .routers import timetable as timetable_router
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cors_urls() -> list[str]:
|
|
28
|
+
settings = get_settings()
|
|
29
|
+
origins = list(settings.cors_origins)
|
|
30
|
+
if settings.env in ("demo", "dev"):
|
|
31
|
+
origins = ["*"]
|
|
32
|
+
return origins
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_app() -> FastAPI:
|
|
36
|
+
settings = get_settings()
|
|
37
|
+
|
|
38
|
+
@asynccontextmanager
|
|
39
|
+
async def lifespan(app: FastAPI):
|
|
40
|
+
session_factory = get_sessionmaker()
|
|
41
|
+
tasks = [
|
|
42
|
+
bell_loop(session_factory),
|
|
43
|
+
background_dispatcher(session_factory),
|
|
44
|
+
]
|
|
45
|
+
for task in tasks:
|
|
46
|
+
app.state.tasks.append(asyncio.create_task(task))
|
|
47
|
+
yield
|
|
48
|
+
for task in app.state.tasks:
|
|
49
|
+
task.cancel()
|
|
50
|
+
await asyncio.gather(*app.state.tasks, return_exceptions=True)
|
|
51
|
+
|
|
52
|
+
app = FastAPI(
|
|
53
|
+
title="SACS - Smart Academic Coordination System",
|
|
54
|
+
version=__version__,
|
|
55
|
+
description="Multi-tenant academic coordination platform: one shared backend, "
|
|
56
|
+
"isolated school tenants, real-time delivery, and a software bell scheduler.",
|
|
57
|
+
lifespan=lifespan,
|
|
58
|
+
)
|
|
59
|
+
app.state.tasks = []
|
|
60
|
+
|
|
61
|
+
app.add_middleware(
|
|
62
|
+
CORSMiddleware,
|
|
63
|
+
allow_origins=cors_urls(),
|
|
64
|
+
allow_credentials=False,
|
|
65
|
+
allow_methods=["*"],
|
|
66
|
+
allow_headers=["*"],
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
app.include_router(schools_router.accept_router)
|
|
70
|
+
app.include_router(auth_router.router)
|
|
71
|
+
app.include_router(platform_router.router)
|
|
72
|
+
app.include_router(schools_router.router)
|
|
73
|
+
app.include_router(academics_router.router)
|
|
74
|
+
app.include_router(timetable_router.router)
|
|
75
|
+
app.include_router(requests_router.router)
|
|
76
|
+
app.include_router(bell_router.router)
|
|
77
|
+
app.include_router(bell_router.test_router)
|
|
78
|
+
app.include_router(realtime_router.router)
|
|
79
|
+
|
|
80
|
+
@app.get("/api/health", tags=["system"])
|
|
81
|
+
def public_health() -> dict:
|
|
82
|
+
return {"status": "ok", "service": "sacs", "version": __version__}
|
|
83
|
+
|
|
84
|
+
@app.get("/", include_in_schema=False)
|
|
85
|
+
def root() -> dict:
|
|
86
|
+
return {
|
|
87
|
+
"service": "sacs",
|
|
88
|
+
"version": __version__,
|
|
89
|
+
"docs": "/docs",
|
|
90
|
+
"health": "/api/health",
|
|
91
|
+
"note": "No browser frontend is shipped; the SACS CLI is the interface.",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return app
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
__all__ = ["create_app"]
|
sacs/audit.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Audit event recording.
|
|
2
|
+
|
|
3
|
+
Audit events are written inside the same transaction as the change they
|
|
4
|
+
describe, so a committed business action always carries an auditable record.
|
|
5
|
+
School-scoped records carry a ``school_id``; platform-level records do not.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import select
|
|
11
|
+
from sqlalchemy.orm import Session as OrmSession
|
|
12
|
+
|
|
13
|
+
from .models import AuditEvent, utcnow
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def record(
|
|
17
|
+
db: OrmSession,
|
|
18
|
+
*,
|
|
19
|
+
school_id: str | None,
|
|
20
|
+
actor_user_id: str | None,
|
|
21
|
+
actor_role: str | None,
|
|
22
|
+
action: str,
|
|
23
|
+
summary: str,
|
|
24
|
+
entity_type: str | None = None,
|
|
25
|
+
entity_id: str | None = None,
|
|
26
|
+
meta: dict | None = None,
|
|
27
|
+
) -> str:
|
|
28
|
+
event = AuditEvent(
|
|
29
|
+
school_id=school_id,
|
|
30
|
+
actor_user_id=actor_user_id,
|
|
31
|
+
actor_role=actor_role,
|
|
32
|
+
action=action,
|
|
33
|
+
summary=summary,
|
|
34
|
+
entity_type=entity_type,
|
|
35
|
+
entity_id=entity_id,
|
|
36
|
+
meta=meta or {},
|
|
37
|
+
)
|
|
38
|
+
db.add(event)
|
|
39
|
+
db.flush()
|
|
40
|
+
return event.id
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def list_events(
|
|
44
|
+
db: OrmSession,
|
|
45
|
+
*,
|
|
46
|
+
school_id: str | None = None,
|
|
47
|
+
platform: bool = False,
|
|
48
|
+
limit: int = 100,
|
|
49
|
+
) -> list[AuditEvent]:
|
|
50
|
+
stmt = select(AuditEvent).order_by(AuditEvent.created_at.desc())
|
|
51
|
+
if platform:
|
|
52
|
+
stmt = stmt.where(AuditEvent.school_id.is_(None))
|
|
53
|
+
elif school_id is not None:
|
|
54
|
+
stmt = stmt.where(AuditEvent.school_id == school_id)
|
|
55
|
+
return list(db.execute(stmt.limit(limit)).scalars().all() or [])
|