Spaces:
Sleeping
Sleeping
File size: 10,788 Bytes
94088ea ca451aa 94088ea | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 | """
Data Access Layer — Repository pattern.
هذه هي الطبقة الوحيدة في النظام التي تعرف أن البيانات مخزّنة في ملفات CSV.
باقي الكود (logic.py + الصفحات) يتعامل مع الـ Repository interface فقط، لذلك
الانتقال إلى Supabase/PostgreSQL لاحقًا = كتابة SupabaseRepository بنفس الدوال،
بدون أي تعديل في منطق الحسابات أو الواجهة.
كل ملف CSV في data/ يطابق جدولًا في PostgreSQL بنفس أسماء الأعمدة حرفيًا.
⚠️ ملاحظة النشر على Hugging Face Spaces:
الكتابة في ملفات CSV هنا تتم على قرص مؤقت (ephemeral filesystem). أي تعديل
(Sync / إضافة نقاط / اعتماد مكافأة) يظهر فورًا ويبقى طوال تشغيل الـ Space،
لكنه يُفقد عند إعادة تشغيل أو إعادة بناء الـ Space. هذا مقبول لنسخة العرض
(demo only) — في الإنتاج تحل Supabase محل هذه الطبقة وتصبح الكتابة دائمة.
"""
from __future__ import annotations
import json
import os
from abc import ABC, abstractmethod
from datetime import datetime
import pandas as pd
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
# حالة الـ Sync تُحفظ خارج data/ عمدًا: مجلد data/ يمثل schema قاعدة البيانات
# بالضبط ويجب أن يبقى قابلًا للترحيل بـ COPY FROM CSV دون أعمدة أو ملفات دخيلة.
# في الإنتاج يصبح هذا الملف جدول synced_tasks أو UNIQUE constraint على المصدر.
SYNC_STATE_FILE = os.path.join(BASE_DIR, "sync_state.json")
class Repository(ABC):
"""الواجهة التي يعتمد عليها باقي النظام. أي مصدر بيانات ينفّذها."""
# ---- قراءة (SELECT) ----
@abstractmethod
def get_employees(self) -> pd.DataFrame: ...
@abstractmethod
def get_points(self) -> pd.DataFrame: ...
@abstractmethod
def get_badges(self) -> pd.DataFrame: ...
@abstractmethod
def get_rewards(self) -> pd.DataFrame: ...
@abstractmethod
def get_reward_requests(self) -> pd.DataFrame: ...
@abstractmethod
def get_notifications(self) -> pd.DataFrame: ...
# ---- كتابة (INSERT / UPDATE) ----
@abstractmethod
def record_points(self, employee_id: str, action: str, points: int,
created_at: str | None = None) -> None: ...
@abstractmethod
def award_badge(self, employee_id: str, badge_name: str,
awarded_at: str | None = None) -> None: ...
@abstractmethod
def send_notification(self, employee_id: str, message: str,
channel: str = "slack") -> None: ...
@abstractmethod
def create_reward_request(self, employee_id: str, reward_id: str,
reward_name: str, points_cost: int) -> None: ...
@abstractmethod
def resolve_reward_request(self, request_id: int, status: str) -> None: ...
@abstractmethod
def get_synced_task_ids(self) -> set[str]: ...
@abstractmethod
def mark_task_synced(self, task_id: str) -> None: ...
def _now() -> str:
"""ISO 8601 بنفس صيغة الداتا الموجودة: 2026-07-01T09:15:00"""
return datetime.now().isoformat(timespec="seconds")
class CSVRepository(Repository):
"""
تنفيذ الـ Repository فوق ملفات CSV باستخدام pandas.
ملاحظة مهمة: الطوابع الزمنية تُقرأ كنصوص (بدون parse_dates) عمدًا، حتى تعود
إلى الملف بنفس صيغتها ISO بالضبط عند الكتابة. التحويل إلى datetime يتم في
logic.py وقت الحساب فقط.
"""
def __init__(self, data_dir: str = DATA_DIR):
self.data_dir = data_dir
def _path(self, table: str) -> str:
return os.path.join(self.data_dir, f"{table}.csv")
def _read(self, table: str, keep_default_na: bool = True) -> pd.DataFrame:
return pd.read_csv(self._path(table), keep_default_na=keep_default_na)
def _write(self, table: str, df: pd.DataFrame) -> None:
# index=False يحافظ على أسماء وترتيب الأعمدة كما هي — شرط الترحيل لـ PostgreSQL
df.to_csv(self._path(table), index=False)
@staticmethod
def _next_id(df: pd.DataFrame) -> int:
"""محاكاة SERIAL PRIMARY KEY."""
if df.empty or "id" not in df.columns:
return 1
return int(pd.to_numeric(df["id"], errors="coerce").max()) + 1
# ---------------- قراءة ----------------
def get_employees(self) -> pd.DataFrame:
# keep_default_na=False ضروري هنا: قيمة current_level النصية "None" مدرجة
# ضمن قيم NA الافتراضية في pandas، فتُقرأ NaN بدل نص. بدون هذا المعامل
# ينكسر حساب ترقية المستوى ويُكتب العمود فارغًا في الملف عند أي تحديث.
return self._read("employees", keep_default_na=False)
def get_points(self) -> pd.DataFrame:
return self._read("employee_points")
def get_badges(self) -> pd.DataFrame:
return self._read("employee_badges")
def get_rewards(self) -> pd.DataFrame:
return self._read("rewards")
def get_reward_requests(self) -> pd.DataFrame:
return self._read("reward_requests")
def get_notifications(self) -> pd.DataFrame:
return self._read("notifications")
# ---------------- كتابة ----------------
def record_points(self, employee_id: str, action: str, points: int,
created_at: str | None = None) -> None:
"""
تسجيل حركة نقاط + تحديث رصيد الموظف.
current_points عمود denormalized (القسم 9 من الـ spec) — أي حركة يجب أن
تحدّث السجل والرصيد معًا. في PostgreSQL هذه transaction واحدة أو trigger.
"""
df = self.get_points()
row = pd.DataFrame([{
"id": self._next_id(df),
"employee_id": employee_id,
"action": action,
"points": int(points),
"created_at": created_at or _now(),
}])
self._write("employee_points", pd.concat([df, row], ignore_index=True))
self._adjust_balance(employee_id, int(points))
def _adjust_balance(self, employee_id: str, delta: int) -> None:
"""
تعديل current_points. المستوى (current_level) يرتفع فقط ولا ينزل:
الدروع إنجاز دائم، وصرف النقاط في المتجر لا يسحب درعًا سبق كسبه.
"""
emp = self.get_employees()
mask = emp["employee_id"] == employee_id
if not mask.any():
raise KeyError(f"موظف غير موجود: {employee_id}")
new_balance = int(emp.loc[mask, "current_points"].iloc[0]) + delta
emp.loc[mask, "current_points"] = new_balance
# استيراد محلي لتفادي دائرة الاستيراد (logic تستورد data_layer)
from logic import LEVEL_ORDER, level_from_points
current = str(emp.loc[mask, "current_level"].iloc[0])
earned = level_from_points(new_balance)
if LEVEL_ORDER.index(earned) > LEVEL_ORDER.index(current):
emp.loc[mask, "current_level"] = earned
self._write("employees", emp)
def award_badge(self, employee_id: str, badge_name: str,
awarded_at: str | None = None) -> None:
df = self.get_badges()
row = pd.DataFrame([{
"id": self._next_id(df),
"employee_id": employee_id,
"badge_name": badge_name,
"awarded_at": awarded_at or _now(),
}])
self._write("employee_badges", pd.concat([df, row], ignore_index=True))
def send_notification(self, employee_id: str, message: str,
channel: str = "slack") -> None:
"""محاكاة Slack/WhatsApp API — في الإنتاج تصبح استدعاء HTTP فعلي."""
df = self.get_notifications()
row = pd.DataFrame([{
"id": self._next_id(df),
"employee_id": employee_id,
"message": message,
"channel": channel,
"sent_at": _now(),
}])
self._write("notifications", pd.concat([df, row], ignore_index=True))
def create_reward_request(self, employee_id: str, reward_id: str,
reward_name: str, points_cost: int) -> None:
df = self.get_reward_requests()
row = pd.DataFrame([{
"id": self._next_id(df),
"employee_id": employee_id,
"reward_id": reward_id,
"reward_name": reward_name,
"points_cost": int(points_cost),
"status": "pending",
"requested_at": _now(),
"resolved_at": "",
}])
self._write("reward_requests", pd.concat([df, row], ignore_index=True))
def resolve_reward_request(self, request_id: int, status: str) -> None:
df = self.get_reward_requests()
mask = df["id"] == request_id
if not mask.any():
raise KeyError(f"طلب غير موجود: {request_id}")
df.loc[mask, "status"] = status
df.loc[mask, "resolved_at"] = _now()
self._write("reward_requests", df)
# ---------------- حالة الـ Sync ----------------
def get_synced_task_ids(self) -> set[str]:
if not os.path.exists(SYNC_STATE_FILE):
return set()
try:
with open(SYNC_STATE_FILE, encoding="utf-8") as f:
return set(json.load(f).get("synced_task_ids", []))
except (json.JSONDecodeError, OSError):
return set()
def mark_task_synced(self, task_id: str) -> None:
ids = self.get_synced_task_ids()
ids.add(task_id)
with open(SYNC_STATE_FILE, "w", encoding="utf-8") as f:
json.dump({"synced_task_ids": sorted(ids)}, f, indent=2)
def reset_sync_state(self) -> None:
"""للعرض فقط: يسمح بإعادة تشغيل الـ Sync من الصفر."""
if os.path.exists(SYNC_STATE_FILE):
os.remove(SYNC_STATE_FILE)
def get_repository() -> Repository:
"""
نقطة التبديل الوحيدة عند الانتقال للإنتاج:
return SupabaseRepository(url=..., key=...)
"""
return CSVRepository()
|