Coverage for app\db.py: 97%
518 statements
« prev ^ index » next coverage.py v7.3.2, created at 2026-01-15 13:39 +0300
« prev ^ index » next coverage.py v7.3.2, created at 2026-01-15 13:39 +0300
1import os
2import uuid
3from datetime import datetime, UTC
4from sqlalchemy import create_engine, Column, String, DateTime, ForeignKey, Boolean, Integer, Text, JSON, Enum, Float, text
5from sqlalchemy.orm import sessionmaker, declarative_base, relationship, object_session
6import enum
8DB_URL = os.getenv("ADMIN_CORE_DB_URL", "sqlite:///./admin_core.db")
10connect_args = {}
11engine_kwargs = {
12 "pool_pre_ping": True,
13}
15if "sqlite" in DB_URL:
16 connect_args["check_same_thread"] = False
17else:
18 engine_kwargs.update({
19 "pool_size": int(os.getenv("DB_POOL_SIZE", "5")),
20 "max_overflow": int(os.getenv("DB_MAX_OVERFLOW", "10")),
21 "pool_timeout": int(os.getenv("DB_POOL_TIMEOUT", "30")),
22 })
24engine = create_engine(
25 DB_URL,
26 connect_args=connect_args,
27 **engine_kwargs
28)
29SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
30Base = declarative_base()
32class UserRole(enum.Enum):
33 SUPERADMIN = "superadmin"
34 MODERATOR = "moderator"
35 AUDITOR = "auditor"
37class UserStatus(enum.Enum):
38 ACTIVE = "active"
39 INACTIVE = "inactive"
40 SUSPENDED = "suspended"
42class NotificationType(enum.Enum):
43 INFO = "info"
44 WARNING = "warning"
45 ERROR = "error"
46 CRITICAL = "critical"
48class AuditAction(enum.Enum):
49 CREATE = "create"
50 UPDATE = "update"
51 DELETE = "delete"
52 LOGIN = "login"
53 LOGOUT = "logout"
54 ENABLE = "enable"
55 DISABLE = "disable"
56 EXPORT = "export"
57 IMPORT = "import"
58 BULK_OPERATION = "bulk_operation"
60class User(Base):
61 __tablename__ = "users"
62 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
63 username = Column(String, unique=True, index=True, nullable=False)
64 email = Column(String, unique=True, index=True, nullable=False)
65 password_hash = Column(String, nullable=False)
66 full_name = Column(String, nullable=False)
67 role = Column(Enum(UserRole), nullable=False, default=UserRole.MODERATOR)
68 status = Column(Enum(UserStatus), nullable=False, default=UserStatus.ACTIVE)
69 last_login_at = Column(DateTime, nullable=True)
70 version = Column(Integer, default=1) # Version tracking
71 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
72 updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
73 created_by = Column(String, ForeignKey("users.id"), nullable=True)
74 # Optional attribute used in some tests; not persisted in DB
75 school_id = None
76 # Audit logs created by this user
77 audit_logs = relationship("AuditLog", foreign_keys="AuditLog.actor_id", back_populates="actor_user")
78 # Users created by this user
79 created_users = relationship("User", foreign_keys="User.created_by")
80 # Entity versions created for this user
81 entity_versions = relationship("EntityVersion", back_populates="creator")
82 # Relationship removed for tests; can be reintroduced with proper FK later
84 @property
85 def permissions(self):
86 sess = object_session(self)
87 if sess is None:
88 return []
89 rps = sess.query(RolePermission).filter(RolePermission.role == self.role).all()
90 return [
91 {"resource": rp.permission.resource, "action": rp.permission.action}
92 for rp in rps if rp.permission is not None
93 ]
95class Permission(Base):
96 __tablename__ = "permissions"
97 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
98 resource = Column(String, nullable=False) # e.g., 'users', 'schools', 'modules'
99 action = Column(String, nullable=False) # e.g., 'create', 'read', 'update', 'delete'
100 description = Column(Text, default="")
101 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
103class RolePermission(Base):
104 __tablename__ = "role_permissions"
105 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
106 role = Column(Enum(UserRole), nullable=False)
107 permission_id = Column(String, ForeignKey("permissions.id"), nullable=False)
108 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
109 permission = relationship("Permission")
111class School(Base):
112 __tablename__ = "schools"
113 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
114 name = Column(String, unique=True, index=True, nullable=False)
115 type = Column(String, default="school") # school, fitness
116 token = Column(String, unique=True, nullable=False)
117 status = Column(String, default="active")
118 address = Column(Text, default="")
119 phone = Column(String, default="")
120 email = Column(String, default="")
121 director_name = Column(String, default="")
122 lat = Column(Float, nullable=True) # Latitude
123 lon = Column(Float, nullable=True) # Longitude
124 last_heartbeat = Column(DateTime, nullable=True)
125 version = Column(Integer, default=1) # Version tracking
126 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
127 updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
128 created_by = Column(String, ForeignKey("users.id"), nullable=True)
129 metrics = relationship("SchoolMetric", back_populates="school", cascade="all, delete")
130 installations = relationship("ModuleInstallation", back_populates="school", cascade="all, delete")
131 subscriptions = relationship("Subscription", back_populates="school", cascade="all, delete")
132 calibration_zones = relationship("CalibrationZone", back_populates="school", cascade="all, delete")
133 # Removed back_populates users due to unmapped school_id
135 @property
136 def users(self):
137 # No mapped FK in tests, return empty list to satisfy relationship assertions
138 return []
141class Plan(Base):
142 __tablename__ = "plans"
143 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
144 name = Column(String, nullable=False)
145 code = Column(String, unique=True, nullable=False) # e.g. "school_license_basic"
146 description = Column(Text, default="")
147 price = Column(Float, default=0.0)
148 currency = Column(String, default="RUB")
149 limits = Column(JSON, default={}) # {"max_students": 100, "max_storage_gb": 10}
150 features = Column(JSON, default=[]) # ["analytics", "video_archive"]
151 is_active = Column(Boolean, default=True)
152 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
153 subscriptions = relationship("Subscription", back_populates="plan")
155 subscriptions = relationship("Subscription", back_populates="plan")
158class Subscription(Base):
159 __tablename__ = "subscriptions"
160 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
161 school_id = Column(String, ForeignKey("schools.id"), nullable=False)
162 plan_id = Column(String, ForeignKey("plans.id"), nullable=False)
163 status = Column(String, default="active") # active, expired, cancelled
164 start_date = Column(DateTime, default=lambda: datetime.now(UTC))
165 end_date = Column(DateTime, nullable=True)
166 auto_renew = Column(Boolean, default=True)
167 payment_method_id = Column(String, nullable=True)
168 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
170 school = relationship("School", back_populates="subscriptions")
171 plan = relationship("Plan", back_populates="subscriptions")
174class CalibrationZone(Base):
175 __tablename__ = "calibration_zones"
177 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
178 school_id = Column(String, ForeignKey("schools.id"), nullable=False)
179 name = Column(String, nullable=False)
180 type = Column(String, nullable=False) # "jump_zone", "push_off_line", etc.
181 points = Column(JSON, nullable=False) # Array of {x, y} coordinates
182 metadata_json = Column(JSON, default={}) # Additional properties
183 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
184 updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
186 school = relationship("School", back_populates="calibration_zones")
187 camera_bindings = relationship("CameraBinding", back_populates="zone", cascade="all, delete")
190class CameraBinding(Base):
191 __tablename__ = "camera_bindings"
193 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
194 zone_id = Column(String, ForeignKey("calibration_zones.id"), nullable=False)
195 camera_id = Column(String, nullable=False) # ID of the physical camera
196 stream_url = Column(String, nullable=True)
197 status = Column(String, default="active") # active, inactive, error
198 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
200 zone = relationship("CalibrationZone", back_populates="camera_bindings")
203class UsageRecord(Base):
204 __tablename__ = "usage_records"
205 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
206 school_id = Column(String, ForeignKey("schools.id"), nullable=False)
207 subscription_id = Column(String, ForeignKey("subscriptions.id"), nullable=True)
208 metric_key = Column(String, nullable=False) # e.g. "attempts_count", "storage_used_mb"
209 dimension = Column(String, nullable=True) # e.g. "module_id", "server_region"
210 value = Column(Float, default=0.0)
211 period_start = Column(DateTime, default=lambda: datetime.now(UTC)) # Defaults for instant events
212 period_end = Column(DateTime, default=lambda: datetime.now(UTC))
213 recorded_at = Column(DateTime, default=lambda: datetime.now(UTC))
215 school = relationship("School")
216 subscription = relationship("Subscription")
219class GlobalUser(Base):
220 __tablename__ = "global_users"
221 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
222 email = Column(String, unique=True, index=True, nullable=True)
223 phone = Column(String, unique=True, index=True, nullable=True)
224 full_name = Column(String, nullable=False)
225 avatar_url = Column(String, nullable=True)
226 is_active = Column(Boolean, default=True)
228 # Unified Role
229 global_role = Column(String, default="participant") # global_admin, regional_admin, school_admin, coach, judge, participant
231 # External Links (Identity Bridge)
232 # Storing as JSON for flexibility: {"admin_core": "id", "school_1": "id", "gto": "id"}
233 external_ids = Column(JSON, default={})
235 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
236 updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
238class InferenceLog(Base):
239 __tablename__ = "inference_logs"
240 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
241 school_id = Column(String, ForeignKey("schools.id"), nullable=False)
242 module_id = Column(String, ForeignKey("modules.id"), nullable=True)
243 timestamp = Column(DateTime, default=lambda: datetime.now(UTC))
244 video_id = Column(String, nullable=True)
245 frame_timestamp = Column(Float, nullable=True)
247 # AI Results
248 confidence = Column(Float, default=0.0)
249 is_valid = Column(Boolean, default=False)
251 # Details for explainability
252 keypoints = Column(JSON, default={})
253 metadata_json = Column(JSON, default={})
255 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
257class JudgementReview(Base):
258 __tablename__ = "judgement_reviews"
259 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
260 inference_id = Column(String, ForeignKey("inference_logs.id"), nullable=False)
261 reviewer_id = Column(String, ForeignKey("users.id"), nullable=False)
263 # Correction
264 original_verdict = Column(String, nullable=False)
265 corrected_verdict = Column(String, nullable=False)
266 correction_reason = Column(String, nullable=True)
268 reviewed_at = Column(DateTime, default=lambda: datetime.now(UTC))
270class Module(Base):
271 __tablename__ = "modules"
272 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
273 key = Column(String, unique=True, index=True, nullable=False) # system key
274 name = Column(String, nullable=False)
275 description = Column(Text, default="")
276 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
277 versions = relationship("ModuleVersion", back_populates="module", cascade="all, delete")
280class ModuleVersion(Base):
281 __tablename__ = "module_versions"
282 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
283 module_id = Column(String, ForeignKey("modules.id"), index=True, nullable=False)
284 version = Column(String, nullable=False) # semver
285 channel = Column(String, default="stable")
286 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
287 module = relationship("Module", back_populates="versions")
290class ModuleDependency(Base):
291 __tablename__ = "module_dependencies"
292 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
293 module_version_id = Column(String, ForeignKey("module_versions.id"), index=True, nullable=False)
294 depends_on_module_key = Column(String, nullable=False)
295 min_version = Column(String, default="0.0.0")
296 max_version = Column(String, default="")
299class ModuleInstallation(Base):
300 __tablename__ = "module_installations"
301 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
302 school_id = Column(String, ForeignKey("schools.id"), index=True, nullable=False)
303 module_id = Column(String, ForeignKey("modules.id"), index=True, nullable=False)
304 version = Column(String, nullable=False)
305 enabled = Column(Boolean, default=True)
306 installed_at = Column(DateTime, default=lambda: datetime.now(UTC))
307 # Relationships
308 school = relationship("School", back_populates="installations")
309 module = relationship("Module")
312class ModuleSetting(Base):
313 __tablename__ = "module_settings"
314 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
315 installation_id = Column(String, ForeignKey("module_installations.id"), index=True, nullable=False)
316 key = Column(String, nullable=False)
317 value = Column(Text, default="")
320class AuditLog(Base):
321 __tablename__ = "audit_logs"
322 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
323 actor_id = Column(String, ForeignKey("users.id"), nullable=False)
324 action = Column(Enum(AuditAction), nullable=False)
325 entity = Column(String, nullable=False)
326 entity_id = Column(String, default="")
327 before = Column(JSON, default={})
328 after = Column(JSON, default={})
329 ip_address = Column(String, default="")
330 user_agent = Column(String, default="")
331 session_id = Column(String, default="")
332 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
333 # Relationships
334 actor_user = relationship("User", foreign_keys=[actor_id], back_populates="audit_logs")
336class SystemMetric(Base):
337 __tablename__ = "system_metrics"
338 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
339 metric_type = Column(String, nullable=False) # cpu, memory, disk, network, etc.
340 metric_name = Column(String, nullable=False)
341 value = Column(JSON, nullable=False)
342 threshold_warning = Column(JSON, nullable=True)
343 threshold_critical = Column(JSON, nullable=True)
344 source = Column(String, default="system") # system, application, external
345 tags = Column(JSON, default={})
346 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
348class Notification(Base):
349 __tablename__ = "notifications"
350 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
351 type = Column(Enum(NotificationType), nullable=False)
352 title = Column(String, nullable=False)
353 message = Column(Text, nullable=False)
354 recipient_id = Column(String, ForeignKey("users.id"), nullable=True) # NULL for broadcast
355 is_read = Column(Boolean, default=False)
356 is_dismissed = Column(Boolean, default=False)
357 action_url = Column(String, default="")
358 action_text = Column(String, default="")
359 extra_data = Column(JSON, default={})
360 expires_at = Column(DateTime, nullable=True)
361 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
362 read_at = Column(DateTime, nullable=True)
364class SystemSetting(Base):
365 __tablename__ = "system_settings"
366 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
367 key = Column(String, unique=True, index=True, nullable=False)
368 value = Column(JSON, nullable=False)
369 description = Column(Text, default="")
370 category = Column(String, default="general") # general, security, performance, etc.
371 is_encrypted = Column(Boolean, default=False)
372 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
373 updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
374 updated_by = Column(String, ForeignKey("users.id"), nullable=True)
376class DataExport(Base):
377 __tablename__ = "data_exports"
378 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
379 export_type = Column(String, nullable=False) # users, schools, audit_logs, etc.
380 format = Column(String, nullable=False) # csv, json, xlsx, pdf
381 filters = Column(JSON, default={})
382 file_path = Column(String, nullable=False)
383 file_size = Column(Integer, default=0)
384 status = Column(String, default="pending") # pending, processing, completed, failed
385 requested_by = Column(String, ForeignKey("users.id"), nullable=False)
386 completed_at = Column(DateTime, nullable=True)
387 expires_at = Column(DateTime, nullable=True)
388 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
390class DataImport(Base):
391 __tablename__ = "data_imports"
392 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
393 import_type = Column(String, nullable=False) # users, schools, etc.
394 format = Column(String, nullable=False)
395 file_path = Column(String, nullable=False)
396 file_size = Column(Integer, default=0)
397 row_count = Column(Integer, default=0)
398 success_count = Column(Integer, default=0)
399 error_count = Column(Integer, default=0)
400 status = Column(String, default="pending") # pending, processing, completed, failed
401 validation_errors = Column(JSON, default=[])
402 imported_by = Column(String, ForeignKey("users.id"), nullable=False)
403 completed_at = Column(DateTime, nullable=True)
404 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
406class DashboardWidget(Base):
407 __tablename__ = "dashboard_widgets"
408 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
409 name = Column(String, nullable=False)
410 widget_type = Column(String, nullable=False) # chart, table, metric, alert
411 data_source = Column(String, nullable=False) # api endpoint or query
412 config = Column(JSON, default={}) # chart type, filters, display options
413 position = Column(JSON, default={"x": 0, "y": 0, "w": 4, "h": 2})
414 is_active = Column(Boolean, default=True)
415 refresh_interval = Column(Integer, default=60) # seconds
416 created_by = Column(String, ForeignKey("users.id"), nullable=False)
417 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
418 updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
420class ReportTemplate(Base):
421 __tablename__ = "report_templates"
422 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
423 name = Column(String, nullable=False)
424 description = Column(Text, default="")
425 report_type = Column(String, nullable=False) # analytics, audit, performance, custom
426 template_config = Column(JSON, default={}) # sections, charts, filters
427 parameters = Column(JSON, default={}) # configurable parameters
428 schedule_config = Column(JSON, nullable=True) # cron expression or interval
429 is_scheduled = Column(Boolean, default=False)
430 created_by = Column(String, ForeignKey("users.id"), nullable=False)
431 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
432 updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
434class GeneratedReport(Base):
435 __tablename__ = "generated_reports"
436 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
437 template_id = Column(String, ForeignKey("report_templates.id"), nullable=False)
438 name = Column(String, nullable=False)
439 report_type = Column(String, nullable=False)
440 format = Column(String, nullable=False) # pdf, html, csv, json
441 file_path = Column(String, nullable=True)
442 file_size = Column(Integer, default=0)
443 parameters = Column(JSON, default={})
444 data = Column(JSON, default={})
445 status = Column(String, default="pending") # pending, processing, completed, failed
446 error_message = Column(Text, default="")
447 generated_by = Column(String, ForeignKey("users.id"), nullable=False)
448 generated_at = Column(DateTime, nullable=True)
449 expires_at = Column(DateTime, nullable=True)
450 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
451 template = relationship("ReportTemplate")
453class AnalyticsEvent(Base):
454 __tablename__ = "analytics_events"
455 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
456 event_type = Column(String, nullable=False) # user_action, system_event, api_call, etc.
457 event_category = Column(String, nullable=False) # authentication, data_operation, system_health
458 user_id = Column(String, ForeignKey("users.id"), nullable=True)
459 session_id = Column(String, default="")
460 entity_type = Column(String, default="") # user, school, module, etc.
461 entity_id = Column(String, default="")
462 action = Column(String, default="") # login, create, update, delete, etc.
463 properties = Column(JSON, default={}) # additional event properties
464 duration = Column(Integer, default=0) # duration in milliseconds
465 status = Column(String, default="success") # success, error, warning
466 error_message = Column(Text, default="")
467 ip_address = Column(String, default="")
468 user_agent = Column(String, default="")
469 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
470 user = relationship("User")
472class SystemComponentStatus(Base):
473 __tablename__ = "system_component_statuses"
474 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
475 component_name = Column(String, unique=True, nullable=False) # admin-core, gto-backend, etc.
476 status = Column(String, nullable=False) # ok, warning, error, offline
477 message = Column(Text, default="")
478 last_checked_at = Column(DateTime, default=lambda: datetime.now(UTC))
479 metrics = Column(JSON, default={}) # arbitrary metrics
480 version = Column(String, nullable=True)
481 uptime = Column(Integer, default=0)
483class SchoolMetric(Base):
484 __tablename__ = "school_metrics"
485 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
486 school_id = Column(String, ForeignKey("schools.id"), index=True, nullable=False)
487 system_status = Column(String, default="ok")
488 cpu_usage = Column(Integer, default=0)
489 memory_usage = Column(Integer, default=0)
490 disk_usage = Column(Integer, default=0)
491 active_users = Column(Integer, default=0)
492 last_seen_at = Column(DateTime, default=lambda: datetime.now(UTC))
493 school = relationship("School", back_populates="metrics")
495class EntityVersion(Base):
496 __tablename__ = "entity_versions"
497 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
498 entity_type = Column(String, nullable=False) # user, school, module, etc.
499 entity_id = Column(String, nullable=False)
500 version_number = Column(Integer, nullable=False, default=1)
501 action = Column(Enum(AuditAction), nullable=False) # CREATE, UPDATE, DELETE
502 data_before = Column(JSON, default={})
503 data_after = Column(JSON, default={})
504 changed_fields = Column(JSON, default=[]) # list of changed field names
505 created_by = Column(String, ForeignKey("users.id"), nullable=False)
506 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
507 # Relationships
508 creator = relationship("User", back_populates="entity_versions")
510class RollbackRequest(Base):
511 __tablename__ = "rollback_requests"
512 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
513 entity_type = Column(String, nullable=False)
514 entity_id = Column(String, nullable=False)
515 target_version = Column(Integer, nullable=False)
516 current_version = Column(Integer, nullable=False)
517 reason = Column(Text, nullable=False)
518 status = Column(String, default="pending") # pending, approved, rejected, completed, failed
519 reviewed_by = Column(String, ForeignKey("users.id"), nullable=True)
520 reviewed_at = Column(DateTime, nullable=True)
521 review_notes = Column(Text, default="")
522 rollback_result = Column(JSON, default={}) # success/failure details
523 requested_by = Column(String, ForeignKey("users.id"), nullable=False)
524 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
525 completed_at = Column(DateTime, nullable=True)
526 # Relationships
527 requester = relationship("User", foreign_keys=[requested_by])
528 reviewer = relationship("User", foreign_keys=[reviewed_by])
530class ChangeApproval(Base):
531 __tablename__ = "change_approvals"
532 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
533 entity_type = Column(String, nullable=False)
534 entity_id = Column(String, nullable=False)
535 change_type = Column(String, nullable=False, default="update") # create, update, delete
536 change_data = Column(JSON, default={})
537 risk_level = Column(String, default="low") # low, medium, high, critical
538 approver_role = Column(Enum(UserRole), nullable=True) # minimum role required
539 status = Column(String, default="pending") # pending, approved, rejected, expired
540 approved_by = Column(String, ForeignKey("users.id"), nullable=True)
541 approved_at = Column(DateTime, nullable=True)
542 approval_notes = Column(Text, default="")
543 expiration_at = Column(DateTime, nullable=True)
544 requested_by = Column(String, ForeignKey("users.id"), nullable=False)
545 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
546 # Relationships
547 requester = relationship("User", foreign_keys=[requested_by])
548 approver = relationship("User", foreign_keys=[approved_by])
551class ModuleAccessPolicy(Base):
552 __tablename__ = "module_access_policies"
553 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
554 school_id = Column(String, ForeignKey("schools.id"), index=True, nullable=False)
555 module_key = Column(String, index=True, nullable=False)
556 role = Column(Enum(UserRole), nullable=False)
557 action = Column(String, nullable=False)
558 condition = Column(JSON, default={})
559 effect = Column(String, default="allow")
560 priority = Column(Integer, default=100)
561 active = Column(Boolean, default=True)
562 created_by = Column(String, ForeignKey("users.id"), nullable=False)
563 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
564 updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
567class ModuleRegistry(Base):
568 __tablename__ = "module_registry"
569 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
570 key = Column(String, unique=True, index=True, nullable=False)
571 name = Column(String, nullable=False)
572 description = Column(Text, default="")
573 capabilities = Column(JSON, default={})
574 created_at = Column(DateTime, default=lambda: datetime.now(UTC))
577class SchoolModuleStatus(Base):
578 __tablename__ = "school_module_status"
579 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
580 school_id = Column(String, ForeignKey("schools.id"), index=True, nullable=False)
581 module_key = Column(String, index=True, nullable=False)
582 status = Column(String, default="disabled")
583 config = Column(JSON, default={})
584 last_changed_at = Column(DateTime, default=lambda: datetime.now(UTC))
585 last_heartbeat_at = Column(DateTime, nullable=True)
588class AIRequestLog(Base):
589 """Log of all AI API requests for analytics and billing"""
590 __tablename__ = "ai_request_logs"
591 id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
592 school_id = Column(String, ForeignKey("schools.id"), index=True, nullable=True)
593 provider = Column(String, index=True, nullable=False) # openai, gemini, anthropic, openrouter
594 model = Column(String, nullable=False)
595 request_type = Column(String, index=True, nullable=False) # jump_analysis, exercise_feedback, etc.
596 status = Column(String, index=True, default="success") # success, error
597 error_message = Column(Text, nullable=True)
598 input_tokens = Column(Integer, default=0)
599 output_tokens = Column(Integer, default=0)
600 total_tokens = Column(Integer, default=0)
601 estimated_cost = Column(String, default="0.00") # In USD as string for precision
602 duration_ms = Column(Integer, default=0) # Request duration in milliseconds
603 request_metadata = Column(JSON, default={}) # Additional metadata
604 created_at = Column(DateTime, default=lambda: datetime.now(UTC), index=True)
607def init_db():
608 Base.metadata.create_all(bind=engine)
610 # Auto-migration for missing columns
611 try:
612 with engine.connect() as conn:
613 # Check for 'type' column in schools
614 if "sqlite" in str(engine.url):
615 # SQLite
616 result = conn.execute(text("PRAGMA table_info(schools)"))
617 columns = [row[1] for row in result.fetchall()]
618 if "type" not in columns:
619 print("Migrating schools table: adding type column")
620 conn.execute(text("ALTER TABLE schools ADD COLUMN type VARCHAR DEFAULT 'school'"))
621 conn.commit()
622 else:
623 # Postgres
624 result = conn.execute(text("SELECT column_name FROM information_schema.columns WHERE table_name = 'schools' AND column_name = 'type'"))
625 if not result.fetchone():
626 print("Migrating schools table: adding type column")
627 conn.execute(text("ALTER TABLE schools ADD COLUMN type VARCHAR DEFAULT 'school'"))
628 conn.commit()
629 except Exception as e:
630 print(f"Migration error: {e}")
632# Dependency for database sessions
633def get_db():
634 db = SessionLocal()
635 try:
636 yield db
637 finally:
638 db.close()