MA مجتبى المدن النسخة المبسّطة ← English

دراسة حالة · شركة الخنيني العالمية المحدودة · يوليو 2023 – الآن

النظام الذي يعرف مَن بيده الجهاز.

منصة داخلية لعهدة أصول تقنية المعلومات: ما الذي تملكه الشركة، ومن يحمله الآن، ومن وقّع على استلامه. مبنية على جانغو وبوستجريس، لتحل محل منتج قديم وجداول بيانات كانت عدة إدارات تحدّثها يدويًا.

البيانات الحية
684 أصلًا · 933 سجل موظف (910 نشط) · 1,423 عملية عهدة · 428 في عهدة موظفين الآن
التقنيات
جانغو 6.0 · بوستجريس · قوالب مُصيَّرة على الخادم · بلا إس بي إيه وبلا خطوة بناء
البيانات المرجعية
22 مشروعًا · 11 تصنيفًا · 10 علامات تجارية
الوصول
5 حسابات موزّعة على 4 مجموعات صلاحيات، مضبوطة على مستوى كل شاشة
الدور
المهندس الوحيد: المخطط، والتطبيق، والنشر، والنسخ الاحتياطي، والتدريب

المستودع خاص: فيه سجلات موظفين وأرقام هويات وتوقيعات ممسوحة، لذلك هذه الصفحة هي التصميم لا الشيفرة: المخطط، وكيف تُكتب عملية تسليم العهدة، والقراران اللذان أدافع عنهما، والقرار الذي كنت سأعكسه.

المشكلة

قبل

كان هناك مصدران للحقيقة نفسها. تطبيق قديم يحمل جزءًا من جرد الأصول، وجداول بيانات تحمل الباقي، إضافة إلى كل ما لم يكن المنتج القديم قادرًا على تمثيله، وهو يشمل الشيء الوحيد الذي كان الناس يحتاجون فعلًا إلى البحث عنه: مَن بيده جهاز معيّن الآن.

والنتائج كانت متوقعة. الأصل نفسه قد يوجد مرتين تحت وسمين مختلفين، وقد يغادر موظف دون أن يُسجَّل في أي مكان أنه أعاد عهدته، وحين يُفقد حاسب محمول، لم يكن هناك مستند عليه توقيع يقول من الذي قبِل المسؤولية عنه.

وهذه النقطة الأخيرة هي التي شكّلت التصميم كله. هذا ليس نظام جرد بالضبط. إنه سجل عهدة، ووظيفته أن يثبت سلسلة الحيازة بعد وقوع الحدث.

نموذج البيانات

6 جداول
14 ترحيلًا

ستة جداول. أهمها ASSETASSIGNMENT، وهو ليس جدول ربط متعدد-إلى-متعدد بل حدث له خصائصه: متى بدأ، ومتى انتهى، وصورتا توقيع. الأصل الذي ليس له سجل عهدة مفتوح موجود في المستودع؛ والذي له سجل مفتوح في يد أحدهم.

BRAND id PK name UQ CATEGORY id PK name UQ ASSET id PK name brand_id FK category_id FK model ← free text serial_no condition price, dates, … ASSETASSIGNMENT id PK asset_id FK employee_id FK project_id FK assigned_at returned_at NULL employee_signature authority_signature notes, emails EMPLOYEE id PK employee_no UQ name project_id FK position is_active contact, govt id PROJECT id PK name code UQ 1 N PROTECT 1 N PROTECT 1 N PROTECT 1 N PROTECT 1 N SET_NULL 1 N SET_NULL
شكل 1: المخطط الحقيقي، بحقول مختصرة. تعليقات on_delete هي التصميم نفسه: كل ما يحمل تاريخ عهدة PROTECT، وكل ما هو تنظيمي SET_NULL، وقيمة returned_at الفارغة تعني أن العهدة ما زالت مفتوحة. السجلات تُغلق ولا تُحذف، فتاريخ عهدة أي أصل يُستخرج باستعلام واحد.

لماذا يرفض نصف المفاتيح الأجنبية الحذف

Asset.brand وAsset.category وAssetAssignment.asset وAssetAssignment.employee كلها on_delete=PROTECT، أما Employee.project وAssetAssignment.project فهما SET_NULL. هذا الفصل مقصود، وهو القرار الذي أدافع عنه أولًا.

لا يمكنك حذف موظف سبق أن وقّع على استلام جهاز. توقيعه هو الدليل، وحفظه هو كل ما وُجد النظام من أجله، فالموظف الذي يغادر يصبح is_active = False؛ و23 من أصل 933 سجلًا مُعلَّمة غير نشطة، ولم يُحذف أي منها، أما المشاريع فمختلفة: انتهاء مشروع أمر طبيعي، ولا يصح أن يأخذ معه تاريخ عهدة الأصول، فتصبح تلك المراجع فارغة ويبقى سجل العهدة.

والنتيجة العملية أن التطبيق يكاد لا يملك مسارات حذف، وأن قاعدة البيانات هي التي تفرض ذلك بدل الوثوق بأن التطبيق سيتذكّره، وهذا كان المقصد من ترك جداول البيانات: قاعدة لا يستطيع أحد تخطّيها بالخطأ.

القيد

الذي لم
يكن يعمل

يجب ألّا يكون الأصل الواحد مفتوحًا لشخصين في وقت واحد. هذا هو الثابت الأساسي، وأول محاولاتي لفرضه كانت خاطئة بطريقة تستحق التدوين.

الترحيل الأول أعلن ما يلي:

0001
unique_together = {("asset", "returned_at")}
0007
unique_together = set()  → أُزيل
شكل 2: بينهما ستة ترحيلات.

كان المقصود: لا يمكن أن يكون للأصل أكثر من سجل واحد قيمة returned_at فيه فارغة، والمشكلة أن هذا لا يعمل، ولا يعمل لسبب يسهل قوله ويسهل نسيانه، ففي إس كيو إل، القيمة الفارغة لا تساوي القيمة الفارغة. الفهرس الفريد يعامل كل قيمة فارغة على أنها مختلفة، فلا يتعارض (الأصل 42، فارغ) مع (الأصل 42، فارغ). أي أن القيد كان يسمح تحديدًا بالحالة التي كُتب ليمنعها، وفي الوقت نفسه قد يعترض على سجلات مشروعة تصادف أن لها الطابع الزمني نفسه للإرجاع.

والأداة الصحيحة هي الفهرس الفريد الجزئي، قاعدة تفرّد تنطبق على مجموعة مُرشَّحة من السجلات فقط، في بوستجريس تُكتب CREATE UNIQUE INDEX … WHERE returned_at IS NULL، وفي جانغو تُكتب هكذا:

القيد
UniqueConstraint(
  fields=["asset"],
  condition=Q(returned_at__isnull=True),
  name="one_open_assignment_per_asset",
)
شكل 3: تفرّد على السجلات المفتوحة وحدها. سجلات العهدة المغلقة غير مقيّدة، فيمكن تسليم الأصل وإرجاعه أي عدد من المرات، لكن لا يمكن أن يكون في عهدة أكثر من شخص في آن واحد.

ولأكون صريحًا بشأن الوضع الحالي: هذا القيد ليس في قاعدة البيانات بعد. منذ الترحيل 0007 وحتى الآن، كان الثابت مفروضًا من مسار التطبيق وحده، إذ يبحث مسار التسليم عن سجل عهدة مفتوح ويغلقه قبل إنشاء التالي، وهو ما يعمل إلى أن يقوم طلبان بذلك في اللحظة نفسها، وهو التغيير التالي الذي أنفّذه، ويأتي مع إصلاح المعاملة المذكور في الثغرات المعروفة أدناه.

والترحيل سيُطبَّق دون مشاكل. راجعت سجلات العهدة المفتوحة الـ428 جميعها ولا يوجد أصل مفتوح أكثر من مرة، فليس هناك ما يحتاج تسوية أولًا، غير أني لا أعدّ ذلك أمانًا، فالشخص الواحد الذي يستخدم أداة داخلية على شبكة محلية نادرًا ما يتسابق مع نفسه.

مسار الكتابة

تسليم
أصل

يتم تسليم الأصول عبر معالج خطوات: اختيار الموظف، ثم أصل واحد أو أكثر، ثم توقيع الطرفين على الشاشة، ثم التأكيد، وخطوة التأكيد هي حيث تعيش كل القواعد.

1 · الصلاحية
@login_required ثم @permission_required("core.add_assetassignment", raise_exception=True)
2 · التواقيع
توقيعا الموظف والجهة المخوَّلة إلزاميان؛ يُحوَّل الـ data-URL من لوحة التوقيع إلى ملف؛ بحد أقصى 19 ميجابايت؛ وتُرفض الرفعات المنتهية
3 · التحقق
تُجمَّع الأخطاء في قائمة وتُعاد على صفحة التأكيد بدل رميها كاستثناء
4 · لكل أصل
البحث عن سجل عهدة قيمته returned_at IS NULL → إغلاقه بـ timezone.now()
5 · لكل أصل
إنشاء سجل العهدة الجديد، وارثًا مشروع الموظف
6 · لكل أصل
إرفاق صورتَي التوقيع، بحفظ مرتين ليتمكن upload_to من تحديد المسار حسب الدور
7 · التحويل
العودة إلى قائمة الأصول
شكل 4: الخطوة 4 نقل لا رفض: تسليم أصل يحمله شخص آخر يغلق عهدته تلقائيًا بدل أن يصدر خطأ، وكان ذلك اختيارًا مقصودًا، فالأجهزة في الواقع تنتقل من مكتب إلى مكتب دون أن تمر بتقنية المعلومات أولًا، والنظام الذي يرفض تسجيل ما وقع فعلًا يُلتَف عليه ببساطة.

توقيعان، ومسألة عالقة في جانغو

كل سجل عهدة يحمل صورتين: الموظف وهو يستلم الجهاز، والجهة المخوَّلة وهي تفرج عنه، ولا واحدة منهما اختيارية، وكلتاهما تُلتقط على لوحة توقيع في المتصفح، وهناك صلاحية منفصلة view_signatures تتحكم بمن يستطيع الاطلاع عليهما لاحقًا، فهي تواقيع أشخاص حقيقيين.

وحفظهما كشف ثغرة صغيرة في جانغو. الدالة المُمرَّرة إلى upload_to تستقبل كائن النموذج واسم الملف، لكنها لا تستقبل أي حقل استدعاها، فمع وجود حقلَي ImageField على نموذج واحد لا تستطيع التمييز بين توقيع الموظف وتوقيع الجهة المخوَّلة، وسيقع كلاهما على المسار نفسه، والحل الذي اتبعته هو ضبط خاصية مؤقتة على الكائن قبل كل عملية حفظ، وقراءتها داخل الدالة لاختيار اسم الملف، ثم حذفها بعد ذلك:

المسار
signatures/<assignment_id>/employee.png
signatures/<assignment_id>/authority.png
الكلفة
استدعاءان إضافيان لـ save(update_fields=[…]) لكل سجل عهدة، لأن المعرّف يجب أن يوجد قبل بناء المسار
شكل 5: الحل يعمل وهو محصور، لكنه حالة مهرَّبة عبر خاصية على الكائن، وكنت أفضّل دالتين صريحتين، وتسمية الملفات حسب الدور بدل حسب الرفع تعني أنه يمكن العثور على توقيع انطلاقًا من معرّف سجل العهدة وحده، وهو ما يهم حين يسأل أحدهم من وقّع على جهاز قبل سنتين.
الواجهة

رسم توضيحي،
لا لقطة شاشة

قوالب جانغو مُصيَّرة على الخادم، بلا إس بي إيه، وبلا خطوة بناء، وبلا إطار جافاسكريبت، والـجافاسكريبت الحقيقي الوحيد هو لوحة التوقيع والنافذة المنبثقة التي تستضيف المعالجات، وكل مدخل في الشريط الجانبي مضبوط بصلاحية، فحسابان في مجموعتين مختلفتين يريان قائمتين مختلفتين.

KIC IT Asset Management System Assign assets  ·  signed in as kic-it
Home Assets Employees Assets history Employees history Brands Categories Projects
Assets + Add asset
Search… Search Export CSV Export Excel
« Prev 1 2 3 … 14 Next »
# ⇅ Asset id ⇅ Name ⇅ S/N ⇅ Brand ⇅ Model ⇅ Status Emp no
10412DesktopXXXXXXXBrand AModel 1000Available–
20413LaptopXXXXXXXBrand BModel 2000Assigned0091
30414Monitor 24"XXXXXXXBrand AModel 24HAssigned0143
40415PrinterXXXXXXXBrand CModel 7740Available–
شكل 6: رسم توضيحي للواجهة، أُعيد رسمه لهذه الصفحة بأسلوبها. ليس لقطة شاشة، وكل علامة تجارية وطراز ورقم تسلسلي ورقم فيه من اختراعي، فالشاشات الحقيقية تحمل أسماء موظفين وأرقام هويات وأرقام جوالات وتواقيع، ولا شيء من ذلك مكانه صفحة عامة، أما ما هو مطابق فهو البنية: شريط جانبي كل مدخل فيه مضبوط بصلاحية، وحقل بحث واحد على الجدول كله، وأعمدة قابلة للفرز، وتصدير سي إس في وإكسل، و14 صفحة من الأصول بهذا الحجم، وعمود حالة يُشتق من سجل العهدة المفتوح لا مخزّن على الأصل، وواجهة النظام نفسها بالإنجليزية.
التشغيل

النسخ الاحتياطي
والاستعادة

أمران إداريان، لأن سجل عهدة يفقد تاريخه أسوأ من عدم وجود سجل أصلًا. backup_core يكتب ثلاثة مخرجات؛ وrestore_core يعيدها، ويمكن تخطي كل جزء على حدة بـ --no-src أو --no-db أو --no-signatures.

قاعدة البيانات
dumpdata --natural-foreign --natural-primary --exclude=contenttypes --exclude=auth.Permission
التواقيع
شجرة تواقيع الوسائط، مضغوطة، فالصور هي الدليل، فتسافر مع السجلات التي تشير إليها
الشيفرة
config/ وcore/ وstatic/ مضغوطة
التسمية
ملف بطابع زمني إضافة إلى مؤشر _latest، ليكون للاستعادة خيار افتراضي معقول
شكل 7: خياران --exclude موجودان بسبب عملية استعادة فشلت، فـdumpdata يُسلسِل contenttypes وauth.Permission، ثم يتعارض loaddata مع السجلات التي أنشأتها ترحيلات جانغو نفسها في قاعدة البيانات الجديدة، واستثناؤهما، مع استخدام المفاتيح الطبيعية ليصمد ارتباط المفاتيح الأجنبية عبر تسلسلات مفاتيح مختلفة، هو ما يجعل الرحلة ذهابًا وإيابًا تعمل فعلًا.

أعرف هذا لأن أول عملية استعادة فشلت بخطأ في تكامل البيانات، واستغرق فهم السبب وقتًا، ويعني ذلك أيضًا أن الجواب الصادق على سؤال «هل سبق أن استعدت نسخة احتياطية؟» هو نعم، أكثر من مرة، وأعرف أي الخيارات تهم.

ما كنت
سأغيّره

أمران

1. كان يجب أن يكون model جدولًا

brand وcategory مفتاحان أجنبيان إلى جداول مرجعية، أما model فحقل CharField نصي حر، وفي حينه بدا ذلك عمليًا، فطُرز الأجهزة بالآلاف ولم أرغب في صيانة فهرس لها، لكن ما اشتراه ذلك فعلًا هو التالي:

الأصول
684
قيم model المختلفة
255
… بعد توحيد حالة الأحرف وإزالة الترقيم
183، أي أن 72 منها كانت مجرد اختلافات في التنسيق
عائلة Dell OptiPlex
326 أصلًا، مكتوبة بـ71 طريقة مختلفة
عائلة Lenovo Think…
84 أصلًا، مكتوبة بـ34 طريقة مختلفة
شكل 8: مقيسة على البيانات الحية، ومن صور OptiPlex المختلفة: OPTIPLEX-7000 وOptiPlex 7000 وOptiplex7000 وOPTI-PLEX 7000 وOPTIPOLEX 7000 وOptiolex 7000 tower. جاء معظمها مع استيراد جداول البيانات القديمة، وكُتب الباقي يدويًا بعد ذلك، لأن لا شيء كان يمنعه.

والضرر العملي أنك لا تستطيع الإجابة عن سؤال «كم لدينا من OptiPlex 7000؟» بـGROUP BY، وهو تحديدًا نوع السؤال الذي بُني النظام للإجابة عنه، فالتجميع على model يعيد 255 مجموعة لثلاثين جهازًا حقيقيًا تقريبًا.

وكشفت مراجعتها صنفًا ثانيًا أقبح من الخلل: مع غياب أي تحقق على عمود نصي حر، تنتهي القيم في العمود الخطأ تمامًا.

الأصول
10 منها الرقم التسلسلي منسوخ داخل model؛ و3 فيها اسم الأصل؛ و25 تحمل قيمة رقمية بديلة
الموظفون
2 اسم الشخص في عمود رقم الهوية و2 في عمود الجوال؛ و6 تحمل القيمة نفسها في العمودين
الاكتمال
561 من أصل 933 موظفًا بلا رقم هوية مسجّل إطلاقًا؛ و27 من الباقين ليست بصيغة الهوية الوطنية ذات العشرة أرقام
شكل 9: لا شيء من هذا خطأ مطبعي يرتكبه إنسان مرتين. هذا ما يفعله استيراد جدول بيانات حين لا تتطابق أعمدة المصدر ولا يعترض شيء بعده، فـCharField بلا مُتحقِّق يقبل أي شيء، وبعد 684 سجلًا تكتشف ما الذي كان يعنيه «أي شيء».

ولذلك للإصلاح نفسه شق ثانٍ: مُتحقِّقات وفحوص صيغة على الأعمدة ذات الشكل المعروف (الهوية الوطنية، ورقم الجوال، والرقم التسلسلي)، مفروضة على مستوى قاعدة البيانات بـCheckConstraint لا في نموذج إدخال فقط، حتى لا يستطيع الاستيراد الجماعي القادم الالتفاف عليها كما فعل السابق.

2. نموذج المستخدم المخصص، أُضيف ثم أُزيل

الترحيل 0013 أدخل نموذج User مخصصًا يرث AbstractUser، بصلاحيتَي edit_users وview_users_readonly، والترحيل 0014 حذفه مرة أخرى.

والدرس خاص بـجانغو وموثّق جيدًا، وتعلّمته رغم ذلك: تبديل AUTH_USER_MODEL قرار تتخذه عمليًا قبل الترحيل الأول لا بعد ثلاثة عشر، فما إن تصبح لجداول أخرى مفاتيح أجنبية إلى auth.User حتى يتحول تغييره إلى مشكلة ترحيل لا تتناسب مع الفائدة، وما كنت أريده فعلًا هو صلاحيات مخصصة، وهي لا تتطلب نموذج مستخدم مخصصًا، ومن هنا جاءت الصلاحيتان على AssetAssignment.

ولا يزال أثران من هذا الالتفاف في الشيفرة: استيراد AbstractUser غير المستخدم في أعلى models.py، وتعليق قديم بجانب AUTH_USER_MODEL. تركتهما إلى أن أكتب هذا بصدق؛ ويجب أن يُحذفا.

الثغرات المعروفة

عمل مفتوح

أشياء أعرف أنها خاطئة ولم أصلحها بعد. ذكرها أرخص من أن تُسأل عنها.

  • مسار التسليم ليس ضمن معاملة. unassign_confirm يعمل داخل transaction.atomic()، أما assign_confirm فلا، إذ يمر على الأصول المختارة، فيغلق سجل عهدة قائمًا وينشئ آخر جديدًا في كل دورة، بقراءة ثم كتابة وبلا قفل على السجل، ولذلك نتيجتان: طلبان متزامنان على الأصل نفسه قد ينشئان سجلَي عهدة مفتوحين، وفشل في منتصف تسليم متعدد الأصول يترك الأصول الأولى مثبَّتة والباقي لا، والإصلاح هو الفهرس الفريد الجزئي أعلاه، مع atomic() حول الحلقة، وselect_for_update() على استعلام سجل العهدة المفتوح.
  • لا توجد اختبارات. ملف core/tests.py ما زال الملف المكوَّن من خمسة أسطر الذي أنشأه startapp، ولنظام قيمته كلها ثابت واحد يخص العهدة، فإن أول اختبار ينبغي كتابته واضح، وغيابه أقل ما في هذه الصفحة قابلية للدفاع.
  • نظافة البيانات المرجعية. إضافة إلى model: 528 من أصل 684 أصلًا بلا وسم خدمة، وعمود invoice_number عمود قديم حلّ محله vendor ولا يزال يحمل محتواه السابق، والقيم البديلة في شكل 9 كلها ما زالت موجودة.
  • بقايا من التفافة نموذج المستخدم. استيراد AbstractUser غير مستخدم، وتعليق قديم بجانب AUTH_USER_MODEL. أمر تافه، لكنه من النوع الذي يجعل القارئ يتساءل عمّا تُرك ناقصًا غيره.

والترتيب مقصود: ثغرة التزامن والاختبارات الغائبة مهمة واحدة، لأن الاختبار الذي يثبت أن الأصل لا يمكن أن يكون في عهدة شخصين هو نفسه الاختبار الذي يثبت أن الإصلاح نجح.