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 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 [])